mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 17:19:33 +02:00
Merge pull request #606: feat(mobile): phone Attach button and camera-roll video uploads
Conflicts resolved against #605 (and #602's test mock): - CLAUDE.md Prompt uploads: #606's rewrite is kept and #605's bounded workspace probe sentence follows the remote (SSH) refusal. - test/routes/session-routes.test.ts: the remote-pane-history and bounded-path-probe mocks are kept beside #606's _resetPasteRateBuckets import, and both PRs' paste-image tests are kept. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6c7d63d45e
24 files changed
+1418
-118
No files matched your search
@@ -113,6 +113,16 @@ export const FILE_PEEK_BYTES = 8 * 1024 - 1; // 8KB (inclusive end offset)
|
||||
*/
|
||||
export const MAX_PASTE_IMAGE_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_IMAGE_BYTES || '') || 50 * 1024 * 1024; // 50MB
|
||||
|
||||
/**
|
||||
* Maximum size (bytes) of a single video uploaded via POST
|
||||
* /api/sessions/:id/paste-image?kind=video (the phone toolbar's Attach button
|
||||
* offers the camera roll's videos beside its photos). Videos stream to disk
|
||||
* rather than through memory, so this is a disk-fill sanity bound, not a memory
|
||||
* bound: a minute of 4K60 from an iPhone is about 400MB, so the image cap would
|
||||
* refuse most clips. Override: CODEMAN_MAX_PASTE_VIDEO_BYTES (bytes)
|
||||
*/
|
||||
export const MAX_PASTE_VIDEO_BYTES = parseInt(process.env.CODEMAN_MAX_PASTE_VIDEO_BYTES || '') || 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
// ============================================================================
|
||||
// File Download Limits
|
||||
// ============================================================================
|
||||
|
||||
@@ -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|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|mp3|wav))\b/g;
|
||||
/(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|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;
|
||||
|
||||
/** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */
|
||||
function absoluteFilePathPattern() {
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
'Restore the grid': '恢复平铺网格',
|
||||
'Remove tile (the session keeps running)': '移除窗格(会话继续运行)',
|
||||
'Drop a tab or a tile here': '将标签页或窗格拖放到此处',
|
||||
// A file dropped on a tile (tile-grid.js) or the single view (image-input.js).
|
||||
'Only image files are supported': '仅支持图像文件',
|
||||
// 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'.
|
||||
'Unsupported file type': '不支持的文件类型',
|
||||
// Redraw (Ctrl+Shift+R, terminal-ui.js restoreTerminalSize) on the main pane, a tile or Pane B.
|
||||
'No active session': '没有活动会话',
|
||||
'This session is sized by its own window': '此会话的尺寸由它自己的窗口决定',
|
||||
@@ -676,6 +677,9 @@
|
||||
// placeholder and label are looked up by the module at build time, since
|
||||
// the DOM translator skips <textarea> subtrees.
|
||||
'Compose prompt': '撰写提示词',
|
||||
Attach: '添加附件',
|
||||
'Open a session to attach a file': '请先打开一个会话再添加附件',
|
||||
'Only image or video files are supported': '仅支持图片或视频文件',
|
||||
'Compose prompt, draft saved': '撰写提示词,草稿已保存',
|
||||
'Resume saved prompt draft': '继续编辑已保存的提示词草稿',
|
||||
'Enter adds a new line': '按 Enter 换行',
|
||||
|
||||
+185
-15
@@ -14,6 +14,69 @@
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
|
||||
initImageInput() {
|
||||
// Phone toolbar Attach (index.html): the tap lands on the file INPUT, which
|
||||
// sits invisibly over the button, and the browser opens the picker itself.
|
||||
//
|
||||
// Nothing here may open the picker from script. While the keyboard is up,
|
||||
// app.js's keyboard tap shim cancels every touch on a toolbar BUTTON and
|
||||
// re-dispatches it as a scripted click, and iOS will not open a file picker
|
||||
// from an untrusted click: measured on iOS 17, `isTrusted: false` two
|
||||
// milliseconds after pointerdown, no picker, no error, and no real click after
|
||||
// it either because the gesture was cancelled. A native tap on the input is not
|
||||
// a button tap, so the shim leaves it alone and the click stays trusted. It also
|
||||
// anchors the picker popover to the input's own frame (a `hidden` input has
|
||||
// none, which is why the composer's picker floated loose).
|
||||
//
|
||||
// Cancelling mousedown's default keeps focus on the terminal's hidden textarea,
|
||||
// so the fixed toolbar never reflows out from under the finger mid-tap.
|
||||
//
|
||||
// It does NOT keep the keyboard up, and nothing here can: iOS resigns first
|
||||
// responder to present the picker, and on DEVICE it does not hand the keyboard
|
||||
// back when the picker closes, even though focus never left the textarea.
|
||||
// (An iOS 27 simulator does hand it back, which is a simulator artifact, so do
|
||||
// not take a keyboard measurement from one.) A blur-then-focus on the close
|
||||
// of the picker, cancel and change alike, restores DOM focus and still leaves
|
||||
// the keyboard down, because iOS only raises it inside a user gesture. So the
|
||||
// user taps the prompt when ready to type, which is the keyboard policy
|
||||
// everywhere else in the app.
|
||||
//
|
||||
// Do not try to hide the keyboard's exit by freezing the page's keyboard
|
||||
// state across the picker. That was tried (holdKeyboardState, reverted) to
|
||||
// avoid the reflow and the two PTY resizes the hide/show pair costs: with the
|
||||
// keyboard gone and the layout still keyboard-sized, the toolbar and accessory
|
||||
// bar strand in the MIDDLE of the screen over a dead black band, which is far
|
||||
// worse than the reflow. The page must follow the keyboard out.
|
||||
const attachBtn = document.getElementById('attachBtnMobile');
|
||||
const attachInput = document.getElementById('attachFileInput');
|
||||
if (attachBtn && attachInput) {
|
||||
attachInput.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
// The button is the ACCESSIBLE control (the input is aria-hidden and not
|
||||
// tabbable), so a keyboard or VoiceOver activation arrives here instead of on
|
||||
// the input. That click is trusted, so opening the picker from it is allowed.
|
||||
attachBtn.addEventListener('click', () => attachInput.click());
|
||||
attachInput.addEventListener('click', (event) => {
|
||||
if (!this.activeSessionId) {
|
||||
event.preventDefault(); // refuse before the picker opens, not after a pick
|
||||
this.showToast('Open a session to attach a file', 'error');
|
||||
return;
|
||||
}
|
||||
// Captured before the picker opens: the session can be deleted while it is
|
||||
// up, and the pick must not land on whatever replaced it.
|
||||
this._attachSessionId = this.activeSessionId;
|
||||
});
|
||||
attachInput.addEventListener('change', () => {
|
||||
const files = Array.from(attachInput.files || []);
|
||||
// Reset so picking the same file again still fires change.
|
||||
attachInput.value = '';
|
||||
// Captured when the picker opened (see the click handler above).
|
||||
const sessionId = this._attachSessionId;
|
||||
this._attachSessionId = null;
|
||||
if (files.length > 0) {
|
||||
void this._attachFromToolbar(files, sessionId).catch((err) => console.warn('Attach failed:', err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag-and-drop handlers on terminal container
|
||||
const container = document.getElementById('terminalContainer');
|
||||
if (!container) return;
|
||||
@@ -38,15 +101,100 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!this.activeSessionId) return;
|
||||
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
||||
|
||||
const imageFiles = Array.from(e.dataTransfer.files).filter((f) => f.type.startsWith('image/'));
|
||||
if (imageFiles.length === 0) {
|
||||
this.showToast('Only image files are supported', 'error');
|
||||
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');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages(imageFiles);
|
||||
this._uploadAndInsertImages(mediaFiles);
|
||||
});
|
||||
},
|
||||
|
||||
/** Upload the toolbar Attach selection and put the paths on the prompt. The
|
||||
* Compose dialog is deliberately not involved (see initImageInput).
|
||||
*
|
||||
* With local echo on (every phone by default) the paths go into the OVERLAY as
|
||||
* pending text rather than to the PTY. That is what makes them part of "what the
|
||||
* user typed": they paint at the prompt, anything typed after them appends in
|
||||
* order, Enter flushes the whole line, and opening Compose adopts path and prose
|
||||
* together (_takePendingLocalEcho reads exactly this buffer). Writing to the PTY
|
||||
* instead would strand the path on the terminal while Compose took only the prose.
|
||||
* Without the overlay (local echo off) there is nothing to buffer into, so the
|
||||
* text goes straight out. The trailing space keeps the next word off the path. */
|
||||
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');
|
||||
return;
|
||||
}
|
||||
if (!sessionId) return; // no captured session: refuse rather than guess at one
|
||||
const paths = await this._uploadAndInsertImages(media, { insert: false, sessionId });
|
||||
if (paths.length === 0) return;
|
||||
// Deleted while the upload ran: a write would go to a dead session, and the
|
||||
// flushed maps below would gain an entry whose cleanup has already run.
|
||||
if (this.sessions && !this.sessions.has(sessionId)) return;
|
||||
const text = paths.join(' ');
|
||||
|
||||
// The prompt composer, if it opened for this session while the upload ran, is
|
||||
// where the prompt now lives: it took the terminal's pending text with it, so
|
||||
// appending behind it would strand the path outside the text being composed.
|
||||
// _insertComposerText adds its own separator and fires the draft save.
|
||||
const openComposer = KeyboardAccessoryBar._composerOverlay;
|
||||
const composerTextarea =
|
||||
openComposer?.isConnected && openComposer.dataset.sessionId === sessionId
|
||||
? openComposer.querySelector('.prompt-composer-textarea')
|
||||
: null;
|
||||
if (composerTextarea) {
|
||||
KeyboardAccessoryBar._insertComposerText(composerTextarea, text);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillActive = this.activeSessionId === sessionId;
|
||||
// The overlay holds the prompt only while local echo actually owns it. A
|
||||
// session handed back to plain PTY echo by a composer nav key (echo
|
||||
// passthrough) skips the whole overlay branch in onData, Enter included, so
|
||||
// text buffered there would sit on screen and never be submitted. Nor while
|
||||
// the tile grid owns the terminal area: the main terminal is parked and
|
||||
// hidden then, and a tile's Enter never reaches its overlay, so the path
|
||||
// goes to the PTY like any other background write.
|
||||
const overlayOwnsPrompt =
|
||||
stillActive &&
|
||||
!this._tilesOwnTerminal?.() &&
|
||||
this._localEchoEnabled &&
|
||||
this._localEchoOverlay &&
|
||||
!this._echoPassthroughSessions?.has(sessionId);
|
||||
if (overlayOwnsPrompt) {
|
||||
// Prompt text the overlay has not adopted yet (a tab completion, a restored
|
||||
// tab) only lands in its flushed half once detection runs, and appendText
|
||||
// runs it AFTER we would have measured. Detect first, or the separator is
|
||||
// computed against an empty string and the path fuses onto a real word.
|
||||
if (!this._localEchoOverlay.pendingText && !this._localEchoOverlay.getFlushed().count) {
|
||||
this._localEchoOverlay.detectBufferText();
|
||||
}
|
||||
const flushed = this._localEchoOverlay.getFlushed().text || '';
|
||||
const prompt = flushed + (this._localEchoOverlay.pendingText || '');
|
||||
const gap = prompt && !/\s$/.test(prompt) ? ' ' : '';
|
||||
this._localEchoOverlay.appendText(gap + text + ' ');
|
||||
} else {
|
||||
// No buffer to measure, so lead with a separator unconditionally rather than
|
||||
// risk fusing onto whatever the CLI's own composer already holds.
|
||||
const out = ` ${text} `;
|
||||
this._sendInputAsync(sessionId, out);
|
||||
// A background session's prompt is tracked in the flushed maps, which is what
|
||||
// a later composer open adopts and erases. An untracked write leaves that
|
||||
// count short, so the erase would eat the tail of this path instead. Not for
|
||||
// a passthrough session: its cursor can sit mid-text, nothing tracks its
|
||||
// composer, and Enter there never clears the maps, so a record would offset
|
||||
// the overlay for the next prompt instead.
|
||||
if (!stillActive && !this._echoPassthroughSessions?.has(sessionId)) {
|
||||
if (!this._flushedOffsets) this._flushedOffsets = new Map();
|
||||
if (!this._flushedTexts) this._flushedTexts = new Map();
|
||||
this._flushedTexts.set(sessionId, (this._flushedTexts.get(sessionId) || '') + out);
|
||||
this._flushedOffsets.set(sessionId, (this._flushedOffsets.get(sessionId) || 0) + out.length);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Called from customKeyEventHandler in terminal-ui.js on Ctrl+V keydown.
|
||||
// Creates a hidden paste trap, lets the browser paste into it, then inspects
|
||||
// the result for images. Works on plain HTTP (no Clipboard API needed).
|
||||
@@ -82,15 +230,13 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (pasteConsumed) return;
|
||||
pasteConsumed = true;
|
||||
|
||||
// Check for images in clipboard items
|
||||
// Check for images or videos in clipboard items
|
||||
var imageFiles = [];
|
||||
var items = e.clipboardData && e.clipboardData.items;
|
||||
if (items) {
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
var blob = items[i].getAsFile();
|
||||
if (blob && self._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,6 +266,21 @@ 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. */
|
||||
_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';
|
||||
return null;
|
||||
},
|
||||
|
||||
// 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".
|
||||
@@ -136,6 +297,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
async _uploadAndInsertImages(fileList, options = {}) {
|
||||
const sessionId = options.sessionId || this.activeSessionId;
|
||||
if (!sessionId) return [];
|
||||
if (options.sessionId && this.sessions && !this.sessions.has(sessionId)) return [];
|
||||
|
||||
let files = Array.from(fileList || []);
|
||||
if (files.length === 0) return [];
|
||||
@@ -153,7 +315,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
let failReason = ''; // first server reason, shown in the toast so a failure is not just a count
|
||||
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');
|
||||
this.showToast(`Uploading ${Math.min(done + 1, total)}/${total} file${total > 1 ? 's' : ''}…`, 'info');
|
||||
progress();
|
||||
|
||||
// Bounded-concurrency worker pool over the file list.
|
||||
@@ -197,11 +359,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
// 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 (paths.length > 0) parts.push(`${paths.length} file${paths.length > 1 ? 's' : ''} ready`);
|
||||
if (failed > 0) parts.push(failReason ? `${failed} failed: ${failReason}` : `${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);
|
||||
this.showToast(parts.join(' · ') || 'No files uploaded', tone);
|
||||
return paths;
|
||||
},
|
||||
|
||||
@@ -209,7 +371,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
const form = new FormData();
|
||||
form.append('image', file);
|
||||
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image', {
|
||||
// A video takes the server's disk-streamed path and its larger cap; the flag
|
||||
// has to ride the URL because the size budget is fixed before the part is read.
|
||||
const kind = this._promptAttachKind(file) === 'video' ? '?kind=video' : '';
|
||||
const resp = await fetch('/api/sessions/' + sessionId + '/paste-image' + kind, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
@@ -231,9 +396,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
// decode/encode failure the original file is returned unchanged so the server
|
||||
// still gets a chance (and logs a precise diagnostic).
|
||||
async _normalizeImageForUpload(file) {
|
||||
if (file.type === 'image/gif') return file;
|
||||
// Only an image goes through the canvas; a video is uploaded as picked. With no
|
||||
// MIME (some Android file managers) the name's extension stands in, as it does in
|
||||
// _promptAttachKind, so a .gif keeps its frames and a .png its transparency.
|
||||
const name = (file.name || '').toLowerCase();
|
||||
const isGif = file.type === 'image/gif' || (!file.type && name.endsWith('.gif'));
|
||||
if (this._promptAttachKind(file) !== 'image' || isGif) return file;
|
||||
|
||||
const toPng = file.type === 'image/png';
|
||||
const toPng = file.type === 'image/png' || (!file.type && name.endsWith('.png'));
|
||||
const url = URL.createObjectURL(file);
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
@@ -776,6 +776,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button + gear -->
|
||||
<!-- Phone-only Attach (wired in image-input.js): the button opens its own file input,
|
||||
which sits invisibly over the button so iOS anchors the picker popover to it. The
|
||||
Compose dialog is deliberately not involved: it focuses its textarea, which raises
|
||||
the keyboard under the picker sheet. Sits BEFORE the mic so the two share order: 5
|
||||
and source order keeps Attach on the left. -->
|
||||
<span class="btn-attach-mobile-wrap">
|
||||
<button type="button" class="btn-attach-mobile" id="attachBtnMobile" title="Attach" aria-label="Attach">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
||||
<circle cx="8.5" cy="8.5" r="1.5"/>
|
||||
<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">
|
||||
</span>
|
||||
<button class="btn-voice-mobile" id="voiceInputBtnMobile" onclick="VoiceInput.toggle()"
|
||||
title="Voice input" aria-label="Start voice input" aria-pressed="false">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
||||
@@ -1247,7 +1247,11 @@ const KeyboardAccessoryBar = {
|
||||
const sessionId = app.activeSessionId;
|
||||
const pending = this._takePendingLocalEcho(sessionId);
|
||||
const saved = this._composerDrafts.get(sessionId) || '';
|
||||
const initial = saved + pending;
|
||||
// A parked draft and text typed (or attached) on the prompt since are two
|
||||
// separate pieces of prose; one space keeps the draft's last word off the
|
||||
// prompt's first, unless either side already supplies the gap.
|
||||
const needsGap = saved && pending && !/\s$/.test(saved) && !/^\s/.test(pending);
|
||||
const initial = needsGap ? `${saved} ${pending}` : saved + pending;
|
||||
|
||||
this._composerOverlay?._closeComposer?.({ restoreFocus: false });
|
||||
const overlay = document.createElement('div');
|
||||
@@ -1269,7 +1273,7 @@ const KeyboardAccessoryBar = {
|
||||
<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/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1285,6 +1289,10 @@ const KeyboardAccessoryBar = {
|
||||
const fileInput = overlay.querySelector('.paste-file-input');
|
||||
const imageButton = overlay.querySelector('.paste-image');
|
||||
const sendButton = overlay.querySelector('.paste-send');
|
||||
// Disabled alongside Send while an upload runs: this button deletes the draft
|
||||
// and closes the overlay, and the upload would then land its paths in a FRESH
|
||||
// draft, which the next open would prepend to the handed-back text.
|
||||
const terminalButton = overlay.querySelector('.prompt-composer-terminal');
|
||||
const focusTrap = new FocusTrap(overlay);
|
||||
textarea.value = initial;
|
||||
if (initial) this._composerDrafts.set(sessionId, initial);
|
||||
@@ -1292,6 +1300,7 @@ const KeyboardAccessoryBar = {
|
||||
const initialUploads = this._composerUploads.get(sessionId) || 0;
|
||||
imageButton.disabled = initialUploads > 0;
|
||||
sendButton.disabled = initialUploads > 0;
|
||||
terminalButton.disabled = initialUploads > 0;
|
||||
if (initialUploads > 0) imageButton.textContent = 'Uploading…';
|
||||
|
||||
const saveDraft = () => {
|
||||
@@ -1318,7 +1327,7 @@ const KeyboardAccessoryBar = {
|
||||
close({ preserveDraft: false });
|
||||
};
|
||||
const handleImages = async (files) => {
|
||||
const images = Array.from(files || []).filter((file) => file.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((file) => app._promptAttachKind(file));
|
||||
if (images.length === 0 || typeof app._uploadAndInsertImages !== 'function') return;
|
||||
saveDraft();
|
||||
this._composerUploads.set(sessionId, (this._composerUploads.get(sessionId) || 0) + 1);
|
||||
@@ -1335,6 +1344,8 @@ const KeyboardAccessoryBar = {
|
||||
currentImageButton.textContent = count > 0 ? 'Uploading…' : '🖼 Image';
|
||||
}
|
||||
if (currentSendButton) currentSendButton.disabled = count > 0;
|
||||
const currentTerminalButton = currentOverlay?.querySelector('.prompt-composer-terminal');
|
||||
if (currentTerminalButton) currentTerminalButton.disabled = count > 0;
|
||||
};
|
||||
syncUploadUi();
|
||||
try {
|
||||
@@ -1366,9 +1377,8 @@ const KeyboardAccessoryBar = {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const images = Array.from(items)
|
||||
.filter((item) => item.type.startsWith('image/'))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
.filter((file) => file && app._promptAttachKind(file));
|
||||
if (images.length > 0) {
|
||||
event.preventDefault();
|
||||
void handleImages(images);
|
||||
@@ -1382,7 +1392,55 @@ const KeyboardAccessoryBar = {
|
||||
});
|
||||
imageButton.addEventListener('click', () => fileInput.click());
|
||||
fileInput.addEventListener('change', () => void handleImages(fileInput.files));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => close({ focusTerminal: true }));
|
||||
overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => {
|
||||
// Hand the text BACK to the terminal prompt so both surfaces hold the same
|
||||
// prompt, the mirror of the adoption composePrompt() does on open. Only a
|
||||
// single-line draft can make the trip: the prompt transport is single-line
|
||||
// and strips embedded newlines, so a multi-line draft would arrive mangled
|
||||
// and instead stays parked as a draft (the dot on the Compose key shows it).
|
||||
const text = textarea.value;
|
||||
if (!text || text.includes('\n')) {
|
||||
close({ focusTerminal: true });
|
||||
return;
|
||||
}
|
||||
// Refuse an oversized draft BEFORE it is deleted, or the text is gone from
|
||||
// both surfaces. Same cap as Send: the overlay branch lays the whole text out
|
||||
// as DOM lines on every repaint, so the transport's larger paste cap is not
|
||||
// the bound that matters here.
|
||||
if (text.length > this._composerMaxLength) {
|
||||
app.showToast?.(`Prompt is too long to send (maximum ${this._composerMaxLength.toLocaleString()} characters)`, 'error');
|
||||
return;
|
||||
}
|
||||
// Cleared, never kept: the text now lives on the prompt, and a surviving
|
||||
// draft would be prepended to it the next time the composer opens.
|
||||
this._composerDrafts.delete(sessionId);
|
||||
close({ focusTerminal: true, preserveDraft: false });
|
||||
if (
|
||||
app._localEchoEnabled &&
|
||||
app._localEchoOverlay &&
|
||||
app.activeSessionId === sessionId &&
|
||||
!app._tilesOwnTerminal?.() &&
|
||||
!app._echoPassthroughSessions?.has(sessionId)
|
||||
) {
|
||||
// Buffer echo: the prompt is the overlay, so the text goes there unsent.
|
||||
// Passthrough is excluded deliberately: such a session skips the overlay
|
||||
// branch on Enter, so text parked there would never be submitted. The tile
|
||||
// grid is excluded for the same reason _attachFromToolbar is: the main
|
||||
// terminal is parked and hidden then, and a tile's Enter cannot flush its
|
||||
// overlay, so the text goes to the PTY below instead of stranding there.
|
||||
app._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
// Write-through echo (codex) or no local echo at all: there is no buffer
|
||||
// to park text in, because the prompt the user sees IS the CLI's own
|
||||
// composer on the PTY. Checking for the overlay alone left this branch
|
||||
// doing nothing on codex, so the button looked dead there. That composer
|
||||
// may already hold text the open adopted nothing of, so lead with a
|
||||
// separator rather than fuse onto its last word (harmless when empty).
|
||||
app._predictiveEcho?.clearPredictions();
|
||||
app._sendInputAsync(sessionId, ` ${text}`);
|
||||
}
|
||||
this._syncComposerDraftIndicator();
|
||||
});
|
||||
overlay.querySelector('.paste-cancel').addEventListener('click', () => close());
|
||||
sendButton.addEventListener('click', send);
|
||||
overlay.addEventListener('click', (event) => {
|
||||
@@ -1399,7 +1457,7 @@ const KeyboardAccessoryBar = {
|
||||
* 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/* → camera / photo library / files), the most reliable
|
||||
* (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
|
||||
* paste event, we intercept them and upload directly. Support is spotty
|
||||
@@ -1414,13 +1472,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 an image"></textarea>
|
||||
<textarea class="paste-textarea" placeholder="Long-press to paste text — or tap 🖼 to attach a photo or video"></textarea>
|
||||
<div class="paste-actions">
|
||||
<button class="paste-image">🖼 Image</button>
|
||||
<button class="paste-cancel">Cancel</button>
|
||||
<button class="paste-send">Send</button>
|
||||
</div>
|
||||
<input type="file" class="paste-file-input" accept="image/*" multiple hidden>
|
||||
<input type="file" class="paste-file-input" accept="image/*,video/*" multiple hidden>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -1438,10 +1496,10 @@ const KeyboardAccessoryBar = {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter to images, close the dialog, and hand off to the shared
|
||||
// Filter to images and videos, close the dialog, and hand off to the shared
|
||||
// upload+insert pipeline. Returns true if any image was handled.
|
||||
const handleImages = (files) => {
|
||||
const images = Array.from(files || []).filter((f) => f.type.startsWith('image/'));
|
||||
const images = Array.from(files || []).filter((f) => app._promptAttachKind(f));
|
||||
if (images.length === 0) return false;
|
||||
close();
|
||||
if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images);
|
||||
@@ -1458,10 +1516,8 @@ const KeyboardAccessoryBar = {
|
||||
if (!items) return;
|
||||
const imageFiles = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob) imageFiles.push(blob);
|
||||
}
|
||||
const blob = items[i].getAsFile();
|
||||
if (blob && app._promptAttachKind(blob)) imageFiles.push(blob);
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -614,7 +614,9 @@ html.mobile-init .file-browser-panel {
|
||||
|
||||
/* Mobile voice input button in toolbar — initially hidden via inline style,
|
||||
shown by VoiceInput._showButtons() clearing the inline display */
|
||||
.btn-voice-mobile {
|
||||
/* The Attach button shares the mic's box; its toolbar slot is on its wrapper below. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -626,15 +628,48 @@ html.mobile-init .file-browser-panel {
|
||||
color: #93c5fd;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
order: 5; /* Between Run Shell and settings gear */
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg {
|
||||
/* Toolbar slot for both, between Run Shell and the settings gear. Attach's
|
||||
WRAPPER takes it (the button inside is not a toolbar flex item), and at an
|
||||
equal order source order keeps Attach on the left of the mic. */
|
||||
.btn-voice-mobile,
|
||||
.btn-attach-mobile-wrap {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
/* Relative box for the invisible file input that sits over the button and TAKES
|
||||
THE TAPS (see initImageInput): a native tap on the input is the only thing that
|
||||
opens the picker while the keyboard is up, and iOS anchors the picker popover to
|
||||
the input's own frame. overflow: hidden is load-bearing: WebKit does not clip a
|
||||
file input, so its "Choose Files" content (~115px in this 26px box) spilled out
|
||||
invisibly over the mic and took its taps. Chromium clips it, so only iOS showed it. */
|
||||
.btn-attach-mobile-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-attach-mobile-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.btn-voice-mobile svg,
|
||||
.btn-attach-mobile svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.btn-voice-mobile:active {
|
||||
.btn-voice-mobile:active,
|
||||
.btn-attach-mobile:active,
|
||||
/* The input above the button swallows the press, so the wrapper is what goes
|
||||
:active. Without this the only phone button with no press feedback is Attach. */
|
||||
.btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: #2a3a5f;
|
||||
}
|
||||
|
||||
@@ -3245,13 +3280,22 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat
|
||||
both stylesheets and repainted the armed modifier back to a resting button on
|
||||
all four light skins. Excluding the state here fixes phone and tablet at once;
|
||||
adding a class to the armed rules would only have moved the tie. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile, .btn-attach-mobile, .btn-settings-mobile, .btn-toolbar.btn-shell, .accessory-btn:not(.armed)) {
|
||||
background: var(--control-bg);
|
||||
border-color: var(--control-border);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) :is(.btn-voice-mobile:active, .btn-attach-mobile:active, .btn-settings-mobile:active, .btn-toolbar.btn-shell:hover, .btn-toolbar.btn-shell:active, .accessory-btn:active) {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Attach's press feedback comes from its wrapper (the file input over the button
|
||||
takes the press). Kept out of the :is() list above on purpose: a descendant
|
||||
selector there would raise that list's specificity for every button in it. */
|
||||
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .btn-attach-mobile-wrap:active .btn-attach-mobile {
|
||||
background: var(--control-bg-hover);
|
||||
border-color: var(--control-border-hover);
|
||||
color: var(--text);
|
||||
|
||||
@@ -6152,6 +6152,12 @@ html[data-skin="og"] .welcome-primary:hover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Attach button (wrapper holds the button and its file input) - hidden on
|
||||
desktop; the phone block in mobile.css shows it */
|
||||
.btn-attach-mobile-wrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile case settings gear - hidden on desktop */
|
||||
.btn-case-settings-mobile {
|
||||
display: none;
|
||||
|
||||
@@ -324,12 +324,14 @@ Object.assign(CodemanApp.prototype, {
|
||||
const sessionId = tileAt(e.target);
|
||||
const files = Array.from(e.dataTransfer?.files || []);
|
||||
if (!sessionId || files.length === 0) return;
|
||||
const images = files.filter((f) => String(f?.type || '').startsWith('image/'));
|
||||
if (images.length === 0) {
|
||||
this.showToast?.('Only image files are supported', 'error');
|
||||
// The one classifier every pick and drop filter reads (image-input.js):
|
||||
// MIME first, then the name's extension, so documents and videos count too.
|
||||
const media = files.filter((f) => this._promptAttachKind?.(f));
|
||||
if (media.length === 0) {
|
||||
this.showToast?.('Unsupported file type', 'error');
|
||||
return;
|
||||
}
|
||||
this._uploadAndInsertImages?.(images, { sessionId });
|
||||
this._uploadAndInsertImages?.(media, { sessionId });
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -155,7 +155,8 @@ import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayout
|
||||
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 { MAX_PASTE_IMAGE_BYTES, MAX_PASTE_VIDEO_BYTES } from '../../config/buffer-limits.js';
|
||||
import { VIDEO_ATTACHMENT_EXTENSIONS } from '../../attachment-registry.js';
|
||||
import { dataPath, getDataDir } from '../../config/instance.js';
|
||||
import {
|
||||
checkRemoteTmuxAvailable,
|
||||
@@ -360,6 +361,19 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
|
||||
const brand = data.subarray(8, 12).toString('ascii');
|
||||
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
|
||||
}
|
||||
case '.mp4':
|
||||
case '.m4v':
|
||||
case '.mov':
|
||||
// ISO Base Media File Format / QuickTime: size + a top-level atom type. Brands
|
||||
// vary too widely to enumerate (qt, isom, mp42, avc1, hev1, ...), and an older
|
||||
// QuickTime file can open on an atom other than ftyp.
|
||||
return ['ftyp', 'moov', 'mdat', 'wide', 'free', 'skip'].includes(data.subarray(4, 8).toString('ascii'));
|
||||
case '.webm':
|
||||
// EBML header (Matroska's WebM subset)
|
||||
return u32be(0) === 0x1a45dfa3;
|
||||
case '.ogv':
|
||||
// Ogg container ("OggS")
|
||||
return u32be(0) === 0x4f676753;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -412,37 +426,70 @@ async function ensureUploadDir(workingDir: string): Promise<string | null> {
|
||||
return uploadDir;
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token bucket for paste-image. 30 requests/minute.
|
||||
// Bucket map entries are pruned when they drift > 1h stale to bound memory
|
||||
// against a flood of unique IP keys.
|
||||
/** Open a fresh, uniquely named paste file for writing. */
|
||||
async function openPasteFile(imageDir: string, ext: string) {
|
||||
// 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}`;
|
||||
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.
|
||||
const fh = await fs.open(
|
||||
filepath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
return { filename, filepath, fh };
|
||||
}
|
||||
|
||||
// Per-(IP, sessionId) token buckets for paste-image. Images: 30/min. Videos: 3/min,
|
||||
// a much lower rate because one video can be up to MAX_PASTE_VIDEO_BYTES (1GB), so the
|
||||
// 30/min cap would let a single authenticated client write 30GB of disk a minute; 3/min
|
||||
// bounds that to 3GB. Bucket entries are pruned when they drift > 1h stale to bound
|
||||
// memory against a flood of unique IP keys.
|
||||
const PASTE_RATE_TOKENS = 30;
|
||||
const PASTE_RATE_REFILL_PER_MS = PASTE_RATE_TOKENS / 60_000;
|
||||
const VIDEO_RATE_TOKENS = 3;
|
||||
const PASTE_BUCKET_TTL_MS = 60 * 60 * 1000;
|
||||
const PASTE_BUCKET_GC_THRESHOLD = 1000;
|
||||
const pasteRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
||||
const videoRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
|
||||
|
||||
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
|
||||
if (pasteRateBuckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
||||
for (const [k, b] of pasteRateBuckets) {
|
||||
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) pasteRateBuckets.delete(k);
|
||||
function takeToken(
|
||||
buckets: Map<string, { tokens: number; lastRefill: number }>,
|
||||
key: string,
|
||||
capacity: number,
|
||||
now: number
|
||||
): boolean {
|
||||
if (buckets.size > PASTE_BUCKET_GC_THRESHOLD) {
|
||||
for (const [k, b] of buckets) {
|
||||
if (now - b.lastRefill > PASTE_BUCKET_TTL_MS) buckets.delete(k);
|
||||
}
|
||||
}
|
||||
let b = pasteRateBuckets.get(key);
|
||||
let b = buckets.get(key);
|
||||
if (!b) {
|
||||
b = { tokens: PASTE_RATE_TOKENS, lastRefill: now };
|
||||
pasteRateBuckets.set(key, b);
|
||||
b = { tokens: capacity, lastRefill: now };
|
||||
buckets.set(key, b);
|
||||
}
|
||||
const delta = (now - b.lastRefill) * PASTE_RATE_REFILL_PER_MS;
|
||||
b.tokens = Math.min(PASTE_RATE_TOKENS, b.tokens + delta);
|
||||
b.tokens = Math.min(capacity, b.tokens + (now - b.lastRefill) * (capacity / 60_000));
|
||||
b.lastRefill = now;
|
||||
if (b.tokens < 1) return false;
|
||||
b.tokens -= 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function consumePasteToken(key: string, now: number = Date.now()): boolean {
|
||||
return takeToken(pasteRateBuckets, key, PASTE_RATE_TOKENS, now);
|
||||
}
|
||||
|
||||
// Videos stream to disk under a far larger per-file cap, so they draw on their own,
|
||||
// much lower bucket (see the comment above the constants).
|
||||
export function consumeVideoToken(key: string, now: number = Date.now()): boolean {
|
||||
return takeToken(videoRateBuckets, key, VIDEO_RATE_TOKENS, now);
|
||||
}
|
||||
|
||||
// Test hook: reset between runs.
|
||||
export function _resetPasteRateBuckets(): void {
|
||||
pasteRateBuckets.clear();
|
||||
videoRateBuckets.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5296,6 +5343,17 @@ export function registerSessionRoutes(
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
|
||||
// Single-sourced from the attachment registry (what the file viewer can also play),
|
||||
// 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}`));
|
||||
const VIDEO_MIME_EXT: Record<string, string> = {
|
||||
mp4: '.mp4',
|
||||
quicktime: '.mov',
|
||||
'x-m4v': '.m4v',
|
||||
webm: '.webm',
|
||||
ogg: '.ogv',
|
||||
};
|
||||
// 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) => {
|
||||
@@ -5331,13 +5389,23 @@ export function registerSessionRoutes(
|
||||
}
|
||||
|
||||
const { id } = req.params as { id: string };
|
||||
// `?kind=video` selects the disk-streamed path (see below). It is read here so the
|
||||
// rate check draws on the right bucket before any work: videos get 3/min, images 30.
|
||||
const isVideo = (req.query as { kind?: string }).kind === 'video';
|
||||
|
||||
// Rate limit per (IP, sessionId): 30/min. Defends against disk-fill DoS
|
||||
// — even an authenticated attacker can otherwise loop large image POSTs.
|
||||
if (!consumePasteToken(`${req.ip}:${id}`)) {
|
||||
// Rate limit per (IP, sessionId). Defends against disk-fill DoS — even an
|
||||
// authenticated client can otherwise loop large uploads. Videos get their own,
|
||||
// far lower bucket because the per-file cap is 1GB (see the bucket constants).
|
||||
const rateKey = `${req.ip}:${id}`;
|
||||
if (!(isVideo ? consumeVideoToken(rateKey) : consumePasteToken(rateKey))) {
|
||||
reply.code(429);
|
||||
reply.header('Retry-After', '60');
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Rate limit exceeded (30 uploads/min per session)');
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
isVideo
|
||||
? 'Rate limit exceeded (3 video uploads/min per session)'
|
||||
: 'Rate limit exceeded (30 uploads/min per session)'
|
||||
);
|
||||
}
|
||||
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
@@ -5381,9 +5449,17 @@ export function registerSessionRoutes(
|
||||
// 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.
|
||||
// `?kind=video` (read above for the rate check) is the ONLY way onto the video
|
||||
// path: it selects the larger, disk-streamed budget before the part is seen
|
||||
// (busboy takes its limits up front), and it admits video types alone, so an
|
||||
// image can never ride the video cap through memory. Without it the route is
|
||||
// byte-identical to the image-only version. Truncation is checked on the stream
|
||||
// below rather than thrown, since the file is being written as it arrives.
|
||||
let part: import('@fastify/multipart').MultipartFile | undefined;
|
||||
try {
|
||||
part = await req.file();
|
||||
part = await req.file(
|
||||
isVideo ? { limits: { fileSize: MAX_PASTE_VIDEO_BYTES }, throwFileSizeLimit: false } : undefined
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
reply.code(413);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, getErrorMessage(err) || 'Invalid multipart payload');
|
||||
@@ -5396,6 +5472,73 @@ export function registerSessionRoutes(
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Unexpected field "${part.fieldname}", expected "image"`);
|
||||
}
|
||||
|
||||
if (isVideo) {
|
||||
let ext = '';
|
||||
if (part.filename) {
|
||||
const origExt = extname(part.filename).toLowerCase();
|
||||
if (ALLOWED_VIDEO_EXTS.has(origExt)) ext = origExt;
|
||||
}
|
||||
const videoMime = (part.mimetype || '').toLowerCase().match(/^video\/(mp4|quicktime|x-m4v|webm|ogg)$/);
|
||||
if (videoMime) ext = VIDEO_MIME_EXT[videoMime[1]] ?? ext;
|
||||
if (!ext) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`Unsupported video type. Allowed: ${[...ALLOWED_VIDEO_EXTS].join(', ')}`
|
||||
);
|
||||
}
|
||||
const imageDir = await ensureUploadDir(session.workingDir);
|
||||
if (!imageDir) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} is not a regular directory`);
|
||||
}
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, 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
|
||||
// of the body is drained unwritten (as busboy does past its own size limit),
|
||||
// which is what lets the answer reach the client. A transfer that dies midway
|
||||
// (the phone left the network) leaves no partial file behind.
|
||||
const head = Buffer.alloc(12);
|
||||
let headLen = 0;
|
||||
let mislabeled = false;
|
||||
try {
|
||||
for await (const chunk of part.file) {
|
||||
if (mislabeled) continue;
|
||||
if (headLen < head.length) {
|
||||
headLen += chunk.copy(head, headLen, 0, head.length - headLen);
|
||||
mislabeled = headLen === head.length && !imageMagicMatchesExt(head, ext);
|
||||
if (mislabeled) continue;
|
||||
}
|
||||
// writeFile loops until the whole chunk is on disk; a bare write() may be short.
|
||||
await fh.writeFile(chunk);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
await fs.unlink(filepath).catch(() => {});
|
||||
throw err;
|
||||
} finally {
|
||||
await fh.close();
|
||||
}
|
||||
let failure: { status: number; message: string } | null = null;
|
||||
if (headLen === 0) failure = { status: 400, message: 'Empty file' };
|
||||
else if (!imageMagicMatchesExt(head.subarray(0, headLen), ext)) {
|
||||
console.warn(
|
||||
`[paste-image] video magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${head.subarray(0, headLen).toString('hex')}`
|
||||
);
|
||||
failure = { status: 415, message: `Video bytes do not match declared type ${ext}` };
|
||||
} else if (part.file.truncated) {
|
||||
const maxMb = Math.round(MAX_PASTE_VIDEO_BYTES / (1024 * 1024));
|
||||
failure = { status: 413, message: `File too large (max ${maxMb}MB)` };
|
||||
}
|
||||
if (failure) {
|
||||
await fs.unlink(filepath).catch(() => {});
|
||||
reply.code(failure.status);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, failure.message);
|
||||
}
|
||||
return { path: filepath, filename };
|
||||
}
|
||||
|
||||
let imageBytes: Buffer;
|
||||
try {
|
||||
imageBytes = await part.toBuffer();
|
||||
@@ -5472,16 +5615,7 @@ export function registerSessionRoutes(
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `${UPLOADS_DIR} 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.
|
||||
const filename = `paste-${Date.now()}-${randomBytes(4).toString('hex')}${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.
|
||||
const fh = await fs.open(
|
||||
filepath,
|
||||
fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW
|
||||
);
|
||||
const { filename, filepath, fh } = await openPasteFile(imageDir, ext);
|
||||
try {
|
||||
await fh.writeFile(imageBytes);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user