mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 18:59:33 +01:00
feat(uploads): prompt uploads take documents
The Attach button, the Compose and paste-dialog pickers, terminal drop and the clipboard now take documents beside images and videos: the office formats pdf/docx/pptx/xlsx/zip and the text family txt/md/csv/tsv/json/log/yaml/yml/xml. A document is admitted by its name's extension (the MIME a phone attaches to a .md is anyone's guess), buffered under the same 50MB image cap, and judged on its bytes by the same fail-closed sniffer: a magic-number signature for the binary formats, UTF-8 with no NUL byte for text. It is saved under a sanitized stem of its name, so Q3 report.pdf is findable as paste-<ts>-<hex>-Q3-report.pdf. sh/py/js/html are refused by name with a clear 400. That is a scope choice, not a security property: file-raw already serves non-media text inert, so this keeps the server from storing what it would serve or an agent could run, and html and svg are the ones deliberately kept out. One client classifier (_promptAttachKind) now returns image, video or document, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused only after the image sniff fails, so nothing the image path accepted by its bytes is lost.
This commit is contained in:
1 parent
a8a8bb71a3
commit
244d9a49f1
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