mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(files): preview picker documents and images
This commit is contained in:
@@ -5,5 +5,6 @@
|
||||
feat(mobile): browse and insert local file and folder paths
|
||||
|
||||
Add a root-confined filesystem picker to Link Existing and the extended mobile
|
||||
keyboard bar. Selected paths remain editable at the active prompt, and a new
|
||||
one-tap action clears only the current unsent input without invoking `/clear`.
|
||||
keyboard bar. Selected paths remain editable at the active prompt, supported
|
||||
images/documents/text files open in a safe inline preview, and a new one-tap
|
||||
action clears only the current unsent input without invoking `/clear`.
|
||||
|
||||
@@ -262,8 +262,10 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L
|
||||
### API Routes
|
||||
|
||||
~191 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (15, incl. root-confined `GET /api/filesystem/browse`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
~192 handlers across 20 route files in `src/web/routes/`: system (45, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, `GET /api/codex/status`, `GET /api/gemini/status`, and `GET /api/away-digest`), sessions (32, incl. `GET /api/sessions/unified`, `POST /api/sessions/:id/pin`, `PUT /api/session-order`), orchestrator (10), cases (27, incl. remote hosts CRUD + remote case-link, docker hosts CRUD + `docker-link` + `docker-quickcreate` + export/import + `docker-exports`), ralph (9), plan (8), files (16, incl. root-confined `GET /api/filesystem/browse` and `GET /api/filesystem/preview`, attachment register + list/history + `:attachmentId/raw`/`preview`/`thumbnail` + workspace `file-preview`/`file-thumbnail`), respawn (7), admin (8, multi-user `/api/admin/users*` incl. per-user case folders), mux (5), push (4), scheduled (4, legacy `ScheduledRun`), cron (9, cron-style `CronJob` jobs/runs), teams (2), me (2, `/api/me` + password), search (1, `GET /api/search`), hooks (1), clipboard (1), status-telemetry (1, `POST /api/status-telemetry` ← statusLine exporter), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details.
|
||||
|
||||
**Filesystem path picker**: Link Existing exposes a Browse button, and the extended mobile keyboard exposes `📁 Path` (insert the chosen file/folder path without Enter) plus `⌫ All` (clear only the current unsent prompt, never the agent's `/clear` command). The picker lazily lists one directory through `GET /api/filesystem/browse`, starts at the active session working directory or `/mnt/d`, hides dot entries, blocks sensitive trees and symlink escapes, and only traverses Home, `CASES_DIR`, `/mnt/d`, or extra roots explicitly configured with `CODEMAN_FILE_PICKER_ROOTS`.
|
||||
For supported files, tapping the file opens the responsive preview layer while `Choose` remains the separate path-selection action: `GET /api/filesystem/preview` serves images/PDF inline, converts DOCX/PPTX through the shared conversion cache/limiter, and returns Markdown/TXT/JSON as inert `text/plain`; text is capped at 2MB and binary/document previews at 50MB.
|
||||
|
||||
**HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse<T>` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`).
|
||||
|
||||
|
||||
@@ -70,12 +70,15 @@ export interface ProcessStats {
|
||||
}
|
||||
|
||||
/** A selectable entry returned by the filesystem path-picker API. */
|
||||
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||
|
||||
export interface FilesystemBrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
symlink?: boolean;
|
||||
previewKind?: FilesystemPreviewKind;
|
||||
}
|
||||
|
||||
/** A named root the path picker may browse without escaping its allowlist. */
|
||||
|
||||
@@ -40,6 +40,9 @@ const PathPicker = {
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
_previewOverlay: null,
|
||||
_previewRequestSequence: 0,
|
||||
_previewPreviousFocus: null,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
@@ -108,7 +111,8 @@ const PathPicker = {
|
||||
this._keydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close(true);
|
||||
if (this._previewOverlay) this.closePreview(true);
|
||||
else this.close(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._keydownHandler);
|
||||
@@ -170,7 +174,9 @@ const PathPicker = {
|
||||
for (const entry of data.entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable');
|
||||
if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) {
|
||||
row.classList.add('not-selectable');
|
||||
}
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.type = entry.type;
|
||||
row.setAttribute('role', 'option');
|
||||
@@ -197,6 +203,14 @@ const PathPicker = {
|
||||
chevron.textContent = '\u203A';
|
||||
open.appendChild(chevron);
|
||||
open.addEventListener('click', () => this.load(entry.path));
|
||||
} else if (entry.previewKind) {
|
||||
const preview = document.createElement('span');
|
||||
preview.className = 'path-picker-item-preview';
|
||||
preview.textContent = '\uD83D\uDC41';
|
||||
open.appendChild(preview);
|
||||
open.title = `Preview ${entry.name}`;
|
||||
open.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
open.addEventListener('click', () => this.openPreview(entry));
|
||||
} else if (!this._options.directoriesOnly) {
|
||||
open.addEventListener('click', () => this.select(entry.path));
|
||||
} else {
|
||||
@@ -228,6 +242,102 @@ const PathPicker = {
|
||||
});
|
||||
},
|
||||
|
||||
openPreview(entry) {
|
||||
this.closePreview(false);
|
||||
this._previewPreviousFocus = document.activeElement;
|
||||
const requestSequence = ++this._previewRequestSequence;
|
||||
const params = new URLSearchParams({ path: entry.path });
|
||||
if (this._options?.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
const previewUrl = `/api/filesystem/preview?${params.toString()}`;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-preview-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', `Preview ${entry.name}`);
|
||||
overlay.innerHTML = `
|
||||
<div class="path-preview-dialog">
|
||||
<div class="path-preview-header">
|
||||
<div class="path-preview-heading">
|
||||
<strong class="path-preview-title"></strong>
|
||||
<span class="path-preview-path"></span>
|
||||
</div>
|
||||
<a class="path-preview-open" target="_blank" rel="noopener noreferrer">Open</a>
|
||||
<button type="button" class="path-preview-close" aria-label="Close preview">×</button>
|
||||
</div>
|
||||
<div class="path-preview-body"><div class="path-preview-loading">Loading preview...</div></div>
|
||||
</div>`;
|
||||
overlay.querySelector('.path-preview-title').textContent = entry.name;
|
||||
overlay.querySelector('.path-preview-path').textContent = entry.path;
|
||||
overlay.querySelector('.path-preview-open').href = previewUrl;
|
||||
overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.closePreview(true);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
this._previewOverlay = overlay;
|
||||
|
||||
const body = overlay.querySelector('.path-preview-body');
|
||||
if (entry.previewKind === 'image') {
|
||||
const image = document.createElement('img');
|
||||
image.className = 'path-preview-image';
|
||||
image.alt = entry.name;
|
||||
image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
image.addEventListener('error', () => this.showPreviewError('Image preview failed to load'));
|
||||
image.src = previewUrl;
|
||||
body.appendChild(image);
|
||||
} else if (entry.previewKind === 'text') {
|
||||
fetch(previewUrl)
|
||||
.then(async (response) => {
|
||||
const content = await response.text();
|
||||
if (!response.ok) {
|
||||
let message = 'Text preview failed to load';
|
||||
try {
|
||||
message = JSON.parse(content).error || message;
|
||||
} catch {}
|
||||
throw new Error(message);
|
||||
}
|
||||
return content;
|
||||
})
|
||||
.then((content) => {
|
||||
if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return;
|
||||
const pre = document.createElement('pre');
|
||||
pre.className = 'path-preview-text';
|
||||
pre.textContent = content;
|
||||
body.replaceChildren(pre);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message);
|
||||
});
|
||||
} else {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.className = 'path-preview-frame';
|
||||
frame.title = entry.name;
|
||||
frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove());
|
||||
frame.src = previewUrl;
|
||||
body.appendChild(frame);
|
||||
}
|
||||
overlay.querySelector('.path-preview-close').focus();
|
||||
},
|
||||
|
||||
showPreviewError(message) {
|
||||
const body = this._previewOverlay?.querySelector('.path-preview-body');
|
||||
if (!body) return;
|
||||
const error = document.createElement('div');
|
||||
error.className = 'path-preview-error';
|
||||
error.textContent = message || 'Preview failed to load';
|
||||
body.replaceChildren(error);
|
||||
},
|
||||
|
||||
closePreview(restoreFocus = true) {
|
||||
this._previewRequestSequence += 1;
|
||||
this._previewOverlay?.remove();
|
||||
this._previewOverlay = null;
|
||||
const previousFocus = this._previewPreviousFocus;
|
||||
this._previewPreviousFocus = null;
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (!this._selectedPath || !this._options) return;
|
||||
const selectedPath = this._selectedPath;
|
||||
@@ -240,6 +350,7 @@ const PathPicker = {
|
||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keydownHandler = null;
|
||||
this._loadSequence += 1;
|
||||
this.closePreview(false);
|
||||
this.overlay?.remove();
|
||||
this.overlay = null;
|
||||
const previousFocus = this._previousFocus;
|
||||
|
||||
+156
-1
@@ -11035,11 +11035,16 @@ body.touch-device.cjk-input-visible .main {
|
||||
}
|
||||
|
||||
.path-picker-item-link,
|
||||
.path-picker-item-chevron {
|
||||
.path-picker-item-chevron,
|
||||
.path-picker-item-preview {
|
||||
margin-left: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.path-picker-item-preview {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.path-picker-item-select {
|
||||
align-self: center;
|
||||
margin-right: 6px;
|
||||
@@ -11097,6 +11102,139 @@ body.touch-device.cjk-input-visible .main {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.path-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10030;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
backdrop-filter: blur(5px);
|
||||
-webkit-backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.path-preview-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(1000px, 100%);
|
||||
height: min(860px, calc(100dvh - 36px));
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 72px rgba(0, 0, 0, 0.62);
|
||||
}
|
||||
|
||||
.path-preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 13px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.path-preview-heading {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-preview-title,
|
||||
.path-preview-path {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-preview-title {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.path-preview-path {
|
||||
margin-top: 2px;
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.path-preview-open,
|
||||
.path-preview-close {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-preview-open {
|
||||
padding: 8px 11px;
|
||||
color: var(--accent);
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.path-preview-close {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-preview-body {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.path-preview-loading,
|
||||
.path-preview-error {
|
||||
padding: 24px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.path-preview-error {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.path-preview-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
margin: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.path-preview-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #fff;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.path-preview-text {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 18px;
|
||||
overflow: visible;
|
||||
color: #e5e7eb;
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.path-picker-overlay {
|
||||
align-items: flex-end;
|
||||
@@ -11120,6 +11258,23 @@ body.touch-device.cjk-input-visible .main {
|
||||
.path-picker-current-select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.path-preview-overlay {
|
||||
align-items: stretch;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.path-preview-dialog {
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.path-preview-open {
|
||||
padding: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
|
||||
+163
-41
@@ -4,11 +4,17 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { basename as pathBasename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot } from '../../types.js';
|
||||
import type {
|
||||
ApiResponse,
|
||||
FilesystemBrowseData,
|
||||
FilesystemBrowseEntry,
|
||||
FilesystemBrowseRoot,
|
||||
FilesystemPreviewKind,
|
||||
} from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
@@ -37,7 +43,7 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema } from '../schemas.js';
|
||||
import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -55,8 +61,14 @@ const MIME_TYPES: Record<string, string> = {
|
||||
txt: 'text/plain',
|
||||
};
|
||||
|
||||
function sanitizeDownloadName(fileName: string): string {
|
||||
return fileName.replace(/["\\\r\n]/g, '_');
|
||||
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||
const cleaned = fileName.replace(/["\\\r\n]/g, '_');
|
||||
const fallback = cleaned.replace(/[^\x20-\x7e]/g, '_') || 'file';
|
||||
const encoded = encodeURIComponent(cleaned).replace(
|
||||
/['()*]/g,
|
||||
(char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`
|
||||
);
|
||||
return `${disposition}; filename="${fallback}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
|
||||
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||
@@ -102,13 +114,12 @@ async function serveRawFile(
|
||||
return;
|
||||
}
|
||||
const content = createReadStream(resolvedPath);
|
||||
const safeName = sanitizeDownloadName(fileName);
|
||||
if (download || extension === 'svg') {
|
||||
reply.header(
|
||||
'Content-Type',
|
||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
||||
);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('attachment', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -116,7 +127,7 @@ async function serveRawFile(
|
||||
}
|
||||
|
||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
||||
reply.header('Content-Disposition', `inline; filename="${safeName}"`);
|
||||
reply.header('Content-Disposition', buildContentDisposition('inline', fileName));
|
||||
reply.header('Content-Length', stat.size);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
sendRawStream(reply, content);
|
||||
@@ -204,7 +215,10 @@ async function serveConvertedPreview(
|
||||
|
||||
const content = await fs.readFile(previewPath);
|
||||
reply.header('Content-Type', 'application/pdf');
|
||||
reply.header('Content-Disposition', `inline; filename="${getPreviewPdfDownloadName(fileName, extension)}"`);
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
buildContentDisposition('inline', getPreviewPdfDownloadName(fileName, extension))
|
||||
);
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('Content-Length', content.length);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
@@ -271,12 +285,36 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
||||
};
|
||||
|
||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||
const FILESYSTEM_TEXT_PREVIEW_LIMIT = 2 * 1024 * 1024;
|
||||
const FILESYSTEM_BINARY_PREVIEW_LIMIT = 50 * 1024 * 1024;
|
||||
const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp']);
|
||||
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
|
||||
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function findMatchingPickerRoot(roots: FilesystemBrowseRoot[], candidate: string): FilesystemBrowseRoot | undefined {
|
||||
return roots
|
||||
.filter((root) => isPathWithinRoot(root.path, candidate))
|
||||
.sort((a, b) => b.path.length - a.path.length)[0];
|
||||
}
|
||||
|
||||
function containsHiddenPickerSegment(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel !== '' && rel.split(sep).some((segment) => segment.startsWith('.'));
|
||||
}
|
||||
|
||||
function getFilesystemPreviewKind(fileName: string): FilesystemPreviewKind | undefined {
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
if (FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS.has(extension)) return 'image';
|
||||
if (FILESYSTEM_TEXT_PREVIEW_EXTENSIONS.has(extension)) return 'text';
|
||||
if (FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS.has(extension)) return 'document';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
@@ -339,6 +377,54 @@ async function resolveFilesystemPickerRoots(
|
||||
return roots;
|
||||
}
|
||||
|
||||
type ResolvedFilesystemPickerPath = {
|
||||
candidatePath: string;
|
||||
resolvedPath: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
matchingRoot: FilesystemBrowseRoot;
|
||||
blockedTrees: readonly string[];
|
||||
};
|
||||
|
||||
function throwFilesystemPickerError(statusCode: number, code: ApiErrorCode, message: string): never {
|
||||
throw Object.assign(new Error(message), {
|
||||
statusCode,
|
||||
body: createErrorResponse(code, message),
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerPath(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
requestedPath: string | undefined,
|
||||
sessionId?: string
|
||||
): Promise<ResolvedFilesystemPickerPath> {
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
||||
if (roots.length === 0) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `Path not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoot = findMatchingPickerRoot(roots, resolvedPath);
|
||||
if (!matchingRoot) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
if (containsHiddenPickerSegment(matchingRoot.path, resolvedPath)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Hidden paths are not available in the file picker');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
return { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees: guard.blockedTrees };
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
@@ -457,35 +543,13 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
||||
if (roots.length === 0) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoots = roots
|
||||
.filter((root) => isPathWithinRoot(root.path, resolvedPath))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
const matchingRoot = matchingRoots[0];
|
||||
if (!matchingRoot) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) {
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||
}
|
||||
@@ -532,8 +596,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath));
|
||||
if (!targetRoot) continue;
|
||||
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
@@ -554,7 +618,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue;
|
||||
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
@@ -562,7 +626,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
}
|
||||
}
|
||||
entries.push({ name: entry.name, path: visiblePath, type, size, symlink: symlink || undefined });
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
path: visiblePath,
|
||||
type,
|
||||
size,
|
||||
symlink: symlink || undefined,
|
||||
previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const parentCandidate = resolve(candidatePath, '..');
|
||||
@@ -589,6 +660,57 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
||||
};
|
||||
});
|
||||
|
||||
// Inline preview for files selected through the root-confined filesystem picker.
|
||||
app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise<void> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query);
|
||||
const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath(
|
||||
ctx,
|
||||
requestedPath,
|
||||
sessionId
|
||||
);
|
||||
if (isBlockedPickerPath(resolvedPath, blockedTrees)) {
|
||||
throwFilesystemPickerError(403, ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked');
|
||||
}
|
||||
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(resolvedPath);
|
||||
} catch {
|
||||
throwFilesystemPickerError(404, ApiErrorCode.NOT_FOUND, `File not found: ${candidatePath}`);
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'The preview path must be a file');
|
||||
}
|
||||
|
||||
const fileName = pathBasename(candidatePath);
|
||||
const extension = extname(fileName).slice(1).toLowerCase();
|
||||
const previewKind = getFilesystemPreviewKind(fileName);
|
||||
if (!previewKind) {
|
||||
throwFilesystemPickerError(400, ApiErrorCode.INVALID_INPUT, 'This file type cannot be previewed');
|
||||
}
|
||||
const sizeLimit = previewKind === 'text' ? FILESYSTEM_TEXT_PREVIEW_LIMIT : FILESYSTEM_BINARY_PREVIEW_LIMIT;
|
||||
if (stat.size > sizeLimit) {
|
||||
throwFilesystemPickerError(
|
||||
413,
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
`File too large to preview (${Math.ceil(stat.size / 1024 / 1024)}MB limit: ${sizeLimit / 1024 / 1024}MB)`
|
||||
);
|
||||
}
|
||||
|
||||
reply.header('Cache-Control', 'no-cache');
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
if (previewKind === 'text') {
|
||||
const content = await fs.readFile(resolvedPath, 'utf8');
|
||||
reply.type('text/plain; charset=utf-8').send(content);
|
||||
return;
|
||||
}
|
||||
if (extension === 'docx' || extension === 'pptx') {
|
||||
await serveConvertedPreview(reply, resolvedPath, fileName, extension);
|
||||
return;
|
||||
}
|
||||
await serveRawFile(reply, resolvedPath, fileName, extension);
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -72,6 +72,16 @@ export const FilesystemBrowseQuerySchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/** Query validation for a single allowlisted path-picker file preview. */
|
||||
export const FilesystemPreviewQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema,
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
|
||||
@@ -104,6 +104,15 @@ describe('mobile filesystem picker actions', () => {
|
||||
expect(sessionSource).toContain('directoriesOnly: true');
|
||||
});
|
||||
|
||||
it('keeps Choose separate from safe inline file preview', () => {
|
||||
expect(keyboardSource).toContain('openPreview(entry)');
|
||||
expect(keyboardSource).toContain('/api/filesystem/preview?');
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'image'");
|
||||
expect(keyboardSource).toContain("entry.previewKind === 'text'");
|
||||
expect(keyboardSource).toContain("choose.textContent = 'Choose'");
|
||||
expect(keyboardSource).toContain('pre.textContent = content');
|
||||
});
|
||||
|
||||
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||
const appendText = vi.fn();
|
||||
const sendInput = vi.fn();
|
||||
|
||||
@@ -57,6 +57,9 @@ describe('file-routes', () => {
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
||||
mockedReadFile.mockImplementation(async (path) =>
|
||||
String(path).endsWith('settings.json') ? ('{}' as never) : ('file content' as never)
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -93,9 +96,15 @@ describe('file-routes', () => {
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.path).toBe(path);
|
||||
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', path });
|
||||
expect(body.data.entries.map((entry: { name: string; type: string }) => [entry.name, entry.type])).toEqual([
|
||||
['src', 'directory'],
|
||||
['notes.txt', 'file'],
|
||||
expect(
|
||||
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
||||
entry.name,
|
||||
entry.type,
|
||||
entry.previewKind,
|
||||
])
|
||||
).toEqual([
|
||||
['src', 'directory', undefined],
|
||||
['notes.txt', 'file', 'text'],
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -147,6 +156,87 @@ describe('file-routes', () => {
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
||||
});
|
||||
|
||||
it('rejects direct navigation into a hidden descendant', async () => {
|
||||
const hidden = `${harness.ctx._session.workingDir}/.git`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/filesystem/preview ==========
|
||||
|
||||
describe('GET /api/filesystem/preview', () => {
|
||||
it('serves Markdown as inert plain text inside the active session root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/notes.md`;
|
||||
mockedReadFile.mockImplementation(async (candidate) =>
|
||||
candidate === path ? ('# Safe heading\n<script>alert(1)</script>' as never) : ('{}' as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toContain('text/plain');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.body).toContain('<script>alert(1)</script>');
|
||||
});
|
||||
|
||||
it('rejects unsupported file types', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/archive.exe`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
|
||||
});
|
||||
|
||||
it('rejects hidden files even when requested directly', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/.env`;
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a preview symlink whose real path escapes every allowed root', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/outside.png`;
|
||||
mockedRealpathSync.mockImplementation((candidate: string) =>
|
||||
candidate === path ? ('/etc/shadow' as never) : (candidate as never)
|
||||
);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('caps text previews at 2MB', async () => {
|
||||
const path = `${harness.ctx._session.workingDir}/large.txt`;
|
||||
mockedStat.mockImplementation(async (candidate) =>
|
||||
candidate === path
|
||||
? ({ size: 2 * 1024 * 1024 + 1, isFile: () => true, isDirectory: () => false } as never)
|
||||
: ({ size: 100, isFile: () => true, isDirectory: () => true } as never)
|
||||
);
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/filesystem/preview?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(path)}`,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(413);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/files ==========
|
||||
|
||||
Reference in New Issue
Block a user