mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +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
|
feat(mobile): browse and insert local file and folder paths
|
||||||
|
|
||||||
Add a root-confined filesystem picker to Link Existing and the extended mobile
|
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
|
keyboard bar. Selected paths remain editable at the active prompt, supported
|
||||||
one-tap action clears only the current unsent input without invoking `/clear`.
|
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
|
### 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.
|
~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`.
|
**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`).
|
**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. */
|
/** A selectable entry returned by the filesystem path-picker API. */
|
||||||
|
export type FilesystemPreviewKind = 'image' | 'text' | 'document';
|
||||||
|
|
||||||
export interface FilesystemBrowseEntry {
|
export interface FilesystemBrowseEntry {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
type: 'file' | 'directory';
|
type: 'file' | 'directory';
|
||||||
size?: number;
|
size?: number;
|
||||||
symlink?: boolean;
|
symlink?: boolean;
|
||||||
|
previewKind?: FilesystemPreviewKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A named root the path picker may browse without escaping its allowlist. */
|
/** A named root the path picker may browse without escaping its allowlist. */
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ const PathPicker = {
|
|||||||
_previousFocus: null,
|
_previousFocus: null,
|
||||||
_keydownHandler: null,
|
_keydownHandler: null,
|
||||||
_loadSequence: 0,
|
_loadSequence: 0,
|
||||||
|
_previewOverlay: null,
|
||||||
|
_previewRequestSequence: 0,
|
||||||
|
_previewPreviousFocus: null,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the lazy filesystem browser.
|
* Open the lazy filesystem browser.
|
||||||
@@ -108,7 +111,8 @@ const PathPicker = {
|
|||||||
this._keydownHandler = (event) => {
|
this._keydownHandler = (event) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.close(true);
|
if (this._previewOverlay) this.closePreview(true);
|
||||||
|
else this.close(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('keydown', this._keydownHandler);
|
document.addEventListener('keydown', this._keydownHandler);
|
||||||
@@ -170,7 +174,9 @@ const PathPicker = {
|
|||||||
for (const entry of data.entries) {
|
for (const entry of data.entries) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'path-picker-item';
|
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.path = entry.path;
|
||||||
row.dataset.type = entry.type;
|
row.dataset.type = entry.type;
|
||||||
row.setAttribute('role', 'option');
|
row.setAttribute('role', 'option');
|
||||||
@@ -197,6 +203,14 @@ const PathPicker = {
|
|||||||
chevron.textContent = '\u203A';
|
chevron.textContent = '\u203A';
|
||||||
open.appendChild(chevron);
|
open.appendChild(chevron);
|
||||||
open.addEventListener('click', () => this.load(entry.path));
|
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) {
|
} else if (!this._options.directoriesOnly) {
|
||||||
open.addEventListener('click', () => this.select(entry.path));
|
open.addEventListener('click', () => this.select(entry.path));
|
||||||
} else {
|
} 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() {
|
confirm() {
|
||||||
if (!this._selectedPath || !this._options) return;
|
if (!this._selectedPath || !this._options) return;
|
||||||
const selectedPath = this._selectedPath;
|
const selectedPath = this._selectedPath;
|
||||||
@@ -240,6 +350,7 @@ const PathPicker = {
|
|||||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||||
this._keydownHandler = null;
|
this._keydownHandler = null;
|
||||||
this._loadSequence += 1;
|
this._loadSequence += 1;
|
||||||
|
this.closePreview(false);
|
||||||
this.overlay?.remove();
|
this.overlay?.remove();
|
||||||
this.overlay = null;
|
this.overlay = null;
|
||||||
const previousFocus = this._previousFocus;
|
const previousFocus = this._previousFocus;
|
||||||
|
|||||||
+156
-1
@@ -11035,11 +11035,16 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.path-picker-item-link,
|
.path-picker-item-link,
|
||||||
.path-picker-item-chevron {
|
.path-picker-item-chevron,
|
||||||
|
.path-picker-item-preview {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.path-picker-item-preview {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.path-picker-item-select {
|
.path-picker-item-select {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
@@ -11097,6 +11102,139 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
cursor: default;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.path-picker-overlay {
|
.path-picker-overlay {
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -11120,6 +11258,23 @@ body.touch-device.cjk-input-visible .main {
|
|||||||
.path-picker-current-select {
|
.path-picker-current-select {
|
||||||
flex: 1 1 100%;
|
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 { 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 { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import { homedir } from 'node:os';
|
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 { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||||
import {
|
import {
|
||||||
@@ -37,7 +43,7 @@ import type { SessionAttachmentHistoryItem, SessionState } from '../../types/ses
|
|||||||
import { isSensitivePath } from '../sensitive-path.js';
|
import { isSensitivePath } from '../sensitive-path.js';
|
||||||
import { SseEvent } from '../sse-events.js';
|
import { SseEvent } from '../sse-events.js';
|
||||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.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> = {
|
const MIME_TYPES: Record<string, string> = {
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
@@ -55,8 +61,14 @@ const MIME_TYPES: Record<string, string> = {
|
|||||||
txt: 'text/plain',
|
txt: 'text/plain',
|
||||||
};
|
};
|
||||||
|
|
||||||
function sanitizeDownloadName(fileName: string): string {
|
function buildContentDisposition(disposition: 'inline' | 'attachment', fileName: string): string {
|
||||||
return fileName.replace(/["\\\r\n]/g, '_');
|
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 {
|
function sendRawStream(reply: FastifyReply, content: ReadStream): void {
|
||||||
@@ -102,13 +114,12 @@ async function serveRawFile(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const content = createReadStream(resolvedPath);
|
const content = createReadStream(resolvedPath);
|
||||||
const safeName = sanitizeDownloadName(fileName);
|
|
||||||
if (download || extension === 'svg') {
|
if (download || extension === 'svg') {
|
||||||
reply.header(
|
reply.header(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream'
|
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('Content-Length', stat.size);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendRawStream(reply, content);
|
||||||
@@ -116,7 +127,7 @@ async function serveRawFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream');
|
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('Content-Length', stat.size);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
sendRawStream(reply, content);
|
sendRawStream(reply, content);
|
||||||
@@ -204,7 +215,10 @@ async function serveConvertedPreview(
|
|||||||
|
|
||||||
const content = await fs.readFile(previewPath);
|
const content = await fs.readFile(previewPath);
|
||||||
reply.header('Content-Type', 'application/pdf');
|
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('Cache-Control', 'no-cache');
|
||||||
reply.header('Content-Length', content.length);
|
reply.header('Content-Length', content.length);
|
||||||
reply.header('X-Content-Type-Options', 'nosniff');
|
reply.header('X-Content-Type-Options', 'nosniff');
|
||||||
@@ -271,12 +285,36 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
|||||||
};
|
};
|
||||||
|
|
||||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
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 {
|
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||||
const rel = relative(root, candidate);
|
const rel = relative(root, candidate);
|
||||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
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 {
|
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||||
// The shared sensitive-path matcher describes file locations such as
|
// The shared sensitive-path matcher describes file locations such as
|
||||||
@@ -339,6 +377,54 @@ async function resolveFilesystemPickerRoots(
|
|||||||
return roots;
|
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 {
|
function appendDownloadFlag(url: string): string {
|
||||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
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.
|
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||||
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath(
|
||||||
if (roots.length === 0) {
|
ctx,
|
||||||
reply.code(403);
|
requestedPath,
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
sessionId
|
||||||
}
|
);
|
||||||
|
|
||||||
const fallbackRoot =
|
if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) {
|
||||||
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)) {
|
|
||||||
reply.code(403);
|
reply.code(403);
|
||||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||||
}
|
}
|
||||||
@@ -532,8 +596,8 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath));
|
const targetRoot = findMatchingPickerRoot(roots, targetPath);
|
||||||
if (!targetRoot) continue;
|
if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue;
|
||||||
|
|
||||||
let type: FilesystemBrowseEntry['type'];
|
let type: FilesystemBrowseEntry['type'];
|
||||||
let size: number | undefined;
|
let size: number | undefined;
|
||||||
@@ -554,7 +618,7 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue;
|
if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue;
|
||||||
if (type === 'file' && size === undefined) {
|
if (type === 'file' && size === undefined) {
|
||||||
try {
|
try {
|
||||||
size = (await fs.stat(targetPath)).size;
|
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.
|
// 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, '..');
|
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
|
// File tree listing
|
||||||
app.get('/api/sessions/:id/files', async (req) => {
|
app.get('/api/sessions/:id/files', async (req) => {
|
||||||
const { id } = req.params as { id: string };
|
const { id } = req.params as { id: string };
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ export const FilesystemBrowseQuerySchema = z.object({
|
|||||||
.optional(),
|
.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 ==========
|
// ========== Env Var Allowlist ==========
|
||||||
|
|
||||||
/** Allowlisted env var key prefixes */
|
/** Allowlisted env var key prefixes */
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ describe('mobile filesystem picker actions', () => {
|
|||||||
expect(sessionSource).toContain('directoriesOnly: true');
|
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', () => {
|
it('inserts a selected path into the editable local-echo prompt without sending it', () => {
|
||||||
const appendText = vi.fn();
|
const appendText = vi.fn();
|
||||||
const sendInput = vi.fn();
|
const sendInput = vi.fn();
|
||||||
|
|||||||
@@ -57,6 +57,9 @@ describe('file-routes', () => {
|
|||||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||||
// Default stat
|
// Default stat
|
||||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true, isDirectory: () => true } as never);
|
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 () => {
|
afterEach(async () => {
|
||||||
@@ -93,9 +96,15 @@ describe('file-routes', () => {
|
|||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(body.data.path).toBe(path);
|
expect(body.data.path).toBe(path);
|
||||||
expect(body.data.roots[0]).toEqual({ label: 'Current Folder', 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([
|
expect(
|
||||||
['src', 'directory'],
|
body.data.entries.map((entry: { name: string; type: string; previewKind?: string }) => [
|
||||||
['notes.txt', 'file'],
|
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(res.statusCode).toBe(404);
|
||||||
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.NOT_FOUND });
|
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 ==========
|
// ========== GET /api/sessions/:id/files ==========
|
||||||
|
|||||||
Reference in New Issue
Block a user