mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +01:00
Merge pull request #621 from JDProfresh/feat/prompt-upload-documents
feat(uploads): prompt uploads take documents Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
0866090bc3
18 files changed
+382
-96
No files matched your search
@@ -2047,7 +2047,7 @@ function computeRewriteScrollLine(input) {
|
||||
* a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call.
|
||||
*/
|
||||
const FILE_PATH_LINK_PATTERN =
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
@@ -2100,7 +2100,7 @@ function previewsInFileViewer(filePath) {
|
||||
* {@link findFilePathLinks}.
|
||||
*/
|
||||
const RELATIVE_FILE_PATH_LINK_PATTERN =
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
/(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g;
|
||||
|
||||
/** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */
|
||||
const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1];
|
||||
|
||||
@@ -168,8 +168,8 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js); the single
|
||||
// view's drop (image-input.js) uses 'Only image or video files are supported'.
|
||||
// A file of an unsupported type dropped on a tile (tile-grid.js) or the single
|
||||
// view (image-input.js).
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
@@ -691,7 +691,7 @@
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'Only image or video files are supported': '仅支持图片或视频文件',
|
||||
'📎 Attach': '📎 添加附件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
|
||||
@@ -103,7 +103,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
const mediaFiles = Array.from(e.dataTransfer.files).filter((f) => this._promptAttachKind(f));
|
||||
if (mediaFiles.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
@@ -124,7 +124,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _attachFromToolbar(files, sessionId) {
|
||||
const media = files.filter((f) => this._promptAttachKind(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast('Only image or video files are supported', 'error');
|
||||
this.showToast('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
@@ -230,7 +230,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images or videos in clipboard items
|
||||
// Check for images, videos or documents in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
@@ -266,18 +266,23 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.execCommand('paste');
|
||||
},
|
||||
|
||||
/** What a pick or a paste may upload: 'image', or 'video' for the camera
|
||||
* roll's clips (the server streams those to disk under their own cap), else
|
||||
* null. The one filter behind Attach, drop, the Compose and paste-dialog
|
||||
* pickers and the clipboard handlers. The MIME type decides; some Android
|
||||
* file managers hand over a File with none, so the name's extension is the
|
||||
* fallback, mirroring the server's own allowlists. */
|
||||
/** What a pick or a paste may upload: 'image', 'video' for the camera roll's
|
||||
* clips (the server streams those to disk under their own cap), 'document'
|
||||
* for the office and text files the server admits, else null. The one filter
|
||||
* behind Attach, drop, the Compose and paste-dialog pickers and the clipboard
|
||||
* handlers. For media the MIME type decides; some Android file managers hand
|
||||
* over a File with none, so the name's extension is the fallback. A document
|
||||
* is known by its extension alone, as on the server (the MIME a file manager
|
||||
* gives a .md or .csv is anyone's guess). Mirrors the server's allowlists. */
|
||||
_promptAttachKind(file) {
|
||||
if (file.type.startsWith('image/')) return 'image';
|
||||
if (file.type.startsWith('video/')) return 'video';
|
||||
const ext = (file.name || '').toLowerCase().match(/\.([a-z0-9]+)$/)?.[1] || '';
|
||||
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'heic', 'heif'].includes(ext)) return 'image';
|
||||
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(ext)) return 'video';
|
||||
if (['pdf', 'docx', 'pptx', 'xlsx', 'zip', 'txt', 'md', 'csv', 'tsv', 'json', 'log', 'yaml', 'yml', 'xml'].includes(ext)) {
|
||||
return 'document';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
|
||||
@@ -790,7 +790,7 @@
|
||||
<path d="m21 15-5-5L5 21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*" multiple tabindex="-1" aria-hidden="true">
|
||||
<input type="file" class="btn-attach-mobile-input" id="attachFileInput" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple tabindex="-1" aria-hidden="true">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
|
||||
@@ -1282,11 +1282,11 @@ const KeyboardAccessoryBar = {
|
||||
<textarea class="paste-textarea prompt-composer-textarea" aria-label="Prompt" placeholder="Write your prompt…" autocorrect="on" autocapitalize="sentences" spellcheck="true"></textarea>
|
||||
<div class="paste-actions prompt-composer-actions">
|
||||
<button type="button" class="prompt-composer-terminal">Use terminal keyboard</button>
|
||||
<button type="button" class="paste-image">🖼 Image</button>
|
||||
<button type="button" class="paste-image">📎 Attach</button>
|
||||
<button type="button" class="paste-cancel">Cancel</button>
|
||||
<button type="button" class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1354,7 +1354,7 @@ const KeyboardAccessoryBar = {
|
||||
const currentSendButton = currentOverlay?.querySelector('.paste-send');
|
||||
if (currentImageButton) {
|
||||
currentImageButton.disabled = count > 0;
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '📎 Attach';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
@@ -1469,13 +1469,13 @@ const KeyboardAccessoryBar = {
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
* - Image (picker): the "Image" button opens a native file picker
|
||||
* (accept=image/*,video/* → camera / photo library / files), the most reliable
|
||||
* way to attach a photo on mobile.
|
||||
* - Image (paste): if the browser exposes image blobs on the textarea's
|
||||
* - File (picker): the "Attach" button opens a native file picker
|
||||
* (accept=image/*,video/* plus the document extensions → camera / photo
|
||||
* library / files), the most reliable way to attach anything on mobile.
|
||||
* - File (paste): if the browser exposes file blobs on the textarea's
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
* on mobile, so it is a best-effort enhancement layered on the picker.
|
||||
* All image paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* All upload paths reuse app._uploadAndInsertImages() (image-input.js), which
|
||||
* uploads to /api/sessions/:id/paste-image and inserts the saved path. */
|
||||
pasteFromClipboard() {
|
||||
if (typeof app === 'undefined' || !app.activeSessionId) return;
|
||||
@@ -1488,13 +1488,13 @@ const KeyboardAccessoryBar = {
|
||||
overlay.className = 'paste-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="paste-dialog">
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 📎 to attach a file"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-image">📎 Attach</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1519,8 +1519,8 @@ const KeyboardAccessoryBar = {
|
||||
if (this._sendBracketedPaste(sessionId, text, 'Paste')) close();
|
||||
};
|
||||
|
||||
// Filter to images and videos, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
// Filter to what the server admits, close the dialog, and hand off to the
|
||||
// shared upload+insert pipeline. Returns true if any file was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
if (images.length === 0) return false;
|
||||
@@ -1530,7 +1530,7 @@ const KeyboardAccessoryBar = {
|
||||
return true;
|
||||
};
|
||||
|
||||
// Image picker (camera / photo library) — the reliable mobile path.
|
||||
// File picker (camera / photo library / files) — the reliable mobile path.
|
||||
overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => handleImages(fileInput.files));
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { execFile } from 'node:child_process';
|
||||
import fs from 'node:fs/promises';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
import { isUtf8 } from 'node:buffer';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
@@ -323,18 +324,36 @@ export function stripInkRedrawBloat(buffer: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image bytes against a declared extension. Sniffs the first ~12 bytes
|
||||
* for a known magic-number signature. Defends against polyglots (e.g. HTML or
|
||||
* SVG disguised under a `Content-Type: image/png` header) and against simple
|
||||
* extension-only spoofing — both the multipart filename and the Content-Type
|
||||
* are attacker-controlled, the raw bytes are not.
|
||||
* The text half of the document allowlist: these are judged on their encoding
|
||||
* (valid UTF-8, no NUL byte) rather than on a signature, since plain text has none.
|
||||
*/
|
||||
const TEXT_UPLOAD_EXTS = new Set(['.txt', '.md', '.csv', '.tsv', '.json', '.log', '.yaml', '.yml', '.xml']);
|
||||
|
||||
/**
|
||||
* Validate upload bytes against a declared extension. Sniffs the first ~12 bytes
|
||||
* for a known magic-number signature (a text file is instead required to be
|
||||
* UTF-8 without a NUL byte, the one check plain text allows). Defends against
|
||||
* polyglots (e.g. HTML or SVG disguised under a `Content-Type: image/png`
|
||||
* header) and against simple extension-only spoofing — both the multipart
|
||||
* filename and the Content-Type are attacker-controlled, the raw bytes are not.
|
||||
* Fail-closed: an extension this function does not know is never a match.
|
||||
*
|
||||
* Signatures: https://en.wikipedia.org/wiki/List_of_file_signatures
|
||||
*/
|
||||
export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
if (TEXT_UPLOAD_EXTS.has(ext)) return data.length > 0 && !data.includes(0) && isUtf8(data);
|
||||
if (data.length < 12) return false;
|
||||
const u32be = (off: number): number => data.readUInt32BE(off);
|
||||
switch (ext) {
|
||||
case '.pdf':
|
||||
// "%PDF-"
|
||||
return u32be(0) === 0x25504446 && data[4] === 0x2d;
|
||||
case '.docx':
|
||||
case '.pptx':
|
||||
case '.xlsx':
|
||||
case '.zip':
|
||||
// PK\x03\x04, the local-file header every non-empty zip (OOXML included) opens with
|
||||
return u32be(0) === 0x504b0304;
|
||||
case '.png':
|
||||
return u32be(0) === 0x89504e47 && u32be(4) === 0x0d0a1a0a;
|
||||
case '.jpg':
|
||||
@@ -428,11 +447,27 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of an uploaded document's name that survives into the saved name,
|
||||
* so `Q3 report (final).pdf` is findable as `paste-<ts>-<hex>-Q3-report-final.pdf`.
|
||||
* Only `[A-Za-z0-9._-]` survive, runs of anything else become one `-`, and the
|
||||
* result is capped at 40 chars; nothing in it can be a path separator or a
|
||||
* second extension that matters (the allowlisted extension is appended after).
|
||||
*/
|
||||
function sanitizeUploadStem(filename: string): string {
|
||||
const stem = basename(filename).replace(/\.[^.]*$/, '');
|
||||
return stem
|
||||
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
||||
.replace(/^[-.]+|[-.]+$/g, '')
|
||||
.slice(0, 40);
|
||||
}
|
||||
|
||||
/** Open a fresh, uniquely named paste file for writing. */
|
||||
async function openPasteFile(imageDir: string, ext: string) {
|
||||
async function openPasteFile(imageDir: string, ext: string, stem = '') {
|
||||
// Date.now() collides on same-ms uploads from two tabs (last-write wins
|
||||
// silently). Append 8 hex chars so concurrent pastes get distinct names.
|
||||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${ext}`;
|
||||
// The `paste-` prefix is what the GC (paste-image-gc.ts) matches on.
|
||||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${stem ? `-${stem}` : ''}${ext}`;
|
||||
const filepath = join(imageDir, filename);
|
||||
// O_EXCL: refuse to overwrite (collision is impossible with random suffix,
|
||||
// but defends against TOCTOU). O_NOFOLLOW: refuse if filepath is a symlink.
|
||||
@@ -5350,6 +5385,12 @@ export function registerSessionRoutes(
|
||||
// so an uploaded video is a type that previews. 3gp/mkv are deliberately out: they
|
||||
// would upload but not preview, and a phone's camera roll produces neither.
|
||||
const ALLOWED_VIDEO_EXTS = new Set([...VIDEO_ATTACHMENT_EXTENSIONS].map((e) => `.${e}`));
|
||||
// Documents a user hands an agent to read: the office formats plus the text
|
||||
// family. Curated on purpose, never SUPPORTED_ATTACHMENT_EXTENSIONS or
|
||||
// EDITABLE_EXTENSIONS, which carry sh/py/js/html; a prompt upload is a thing
|
||||
// to read, not a thing to run or serve. Each is judged on its bytes by
|
||||
// imageMagicMatchesExt (signature, or UTF-8 without NUL for text).
|
||||
const ALLOWED_DOCUMENT_EXTS = new Set(['.pdf', '.docx', '.pptx', '.xlsx', '.zip', ...TEXT_UPLOAD_EXTS]);
|
||||
const VIDEO_MIME_EXT: Record<string, string> = {
|
||||
mp4: '.mp4',
|
||||
quicktime: '.mov',
|
||||
@@ -5359,6 +5400,19 @@ export function registerSessionRoutes(
|
||||
};
|
||||
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
|
||||
|
||||
/** Write buffered upload bytes into the session's upload dir. Null: the dir is not a real directory. */
|
||||
async function writeBufferedUpload(workingDir: string, ext: string, bytes: Buffer, stem = '') {
|
||||
const uploadDir = await ensureUploadDir(workingDir);
|
||||
if (!uploadDir) return null;
|
||||
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext, stem);
|
||||
try {
|
||||
await fh.writeFile(bytes);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
return { path: filepath, filename };
|
||||
}
|
||||
|
||||
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
|
||||
// CSRF defense: state-changing routes must come from same origin.
|
||||
// Cookies are SameSite=lax, multipart/form-data is a "simple" CORS request
|
||||
@@ -5492,13 +5546,13 @@ export function registerSessionRoutes(
|
||||
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
|
||||
);
|
||||
}
|
||||
const imageDir = await ensureUploadDir(session.workingDir);
|
||||
if (!imageDir) {
|
||||
const uploadDir = await ensureUploadDir(session.workingDir);
|
||||
if (!uploadDir) {
|
||||
await finished(part.file.resume()).catch(() => {});
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
||||
const { filename, filepath, fh } = await openPasteFile(uploadDir, ext);
|
||||
// Written as it arrives: a phone video is far too large to buffer the way an
|
||||
// image is. The container is judged on the first 12 bytes as soon as they are
|
||||
// in, so a mislabeled upload stops touching disk at its first chunk; the rest
|
||||
@@ -5557,12 +5611,41 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Empty file');
|
||||
}
|
||||
|
||||
// Dispatch mirrors the client's _promptAttachKind: an image MIME type takes
|
||||
// the image path whatever the name says, a document extension (the MIME a
|
||||
// phone gives a .md is anyone's guess) takes the document branch, anything
|
||||
// else the image path exactly as before, HEIC sniff included. See CLAUDE.md.
|
||||
const origExt = part.filename ? extname(part.filename).toLowerCase() : '';
|
||||
const imageMime = /^image\//i.test(part.mimetype || '');
|
||||
if (!imageMime && ALLOWED_DOCUMENT_EXTS.has(origExt)) {
|
||||
if (!imageMagicMatchesExt(imageBytes, origExt)) {
|
||||
// The first bytes of a text file are user content, so for text the log
|
||||
// names the failure and never the bytes; a signature is safe to print.
|
||||
const detail = TEXT_UPLOAD_EXTS.has(origExt)
|
||||
? `reason=${imageBytes.includes(0) ? 'nul-byte' : 'not-utf8'}`
|
||||
: `magic=${imageBytes.subarray(0, 12).toString('hex')}`;
|
||||
console.warn(
|
||||
`[paste-image] document mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${origExt} size=${imageBytes.length} ${detail}`
|
||||
);
|
||||
reply.code(415);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File bytes do not match declared type ${origExt}`);
|
||||
}
|
||||
const saved = await writeBufferedUpload(
|
||||
session.workingDir,
|
||||
origExt,
|
||||
imageBytes,
|
||||
sanitizeUploadStem(part.filename)
|
||||
);
|
||||
if (!saved) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
// Determine extension from filename or Content-Type.
|
||||
let ext = '.png';
|
||||
if (part.filename) {
|
||||
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|heic|heif)$/);
|
||||
if (mimeMatch) {
|
||||
const map: Record<string, string> = {
|
||||
@@ -5603,6 +5686,15 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
|
||||
}
|
||||
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
|
||||
// A foreign extension with no image MIME (a script, an HTML page, a video
|
||||
// without its flag) was never going to be an image: say so, rather than
|
||||
// the ".png" the default extension would name. Decided only HERE, after
|
||||
// the sniff, so anything the image path ever accepted by its bytes (a
|
||||
// HEIC under a generic name) still is.
|
||||
if (origExt && !ALLOWED_IMAGE_EXTS.has(origExt) && !imageMime) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unsupported file type: ${origExt}`);
|
||||
}
|
||||
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
|
||||
// serve back with image/png MIME. Log the real header so format
|
||||
// mismatches can be pinned down without a reproduce-and-guess loop. The
|
||||
@@ -5614,20 +5706,12 @@ export function registerSessionRoutes(
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Image bytes do not match declared type ${ext}`);
|
||||
}
|
||||
|
||||
// {workingDir}/.codeman-uploads/, see ensureUploadDir.
|
||||
const imageDir = await ensureUploadDir(session.workingDir);
|
||||
if (!imageDir) {
|
||||
const saved = await writeBufferedUpload(session.workingDir, ext, imageBytes);
|
||||
if (!saved) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
||||
try {
|
||||
await fh.writeFile(imageBytes);
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
|
||||
return { path: filepath, filename };
|
||||
return saved;
|
||||
});
|
||||
|
||||
// Returned so the server can own the registry's LIFETIME (drop state when a session is
|
||||
|
||||
Reference in new issue
Block a user