/** * @fileoverview File browser and streaming routes. * Provides directory listing, file content preview, raw file serving, and tail streaming. */ import { FastifyInstance, type FastifyReply } from 'fastify'; 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, FilesystemPreviewKind, } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; import { fileStreamManager } from '../../file-stream-manager.js'; import { AttachmentRegistrationError, attachmentRecordToEvent, attachmentRegistry, buildFileThumbnailRoute, isSupportedAttachmentExtension, registerExternalAttachment, type AttachmentRecord, } from '../../attachment-registry.js'; import { generateFirstPageThumbnail } from '../../document-thumbnailer.js'; import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js'; import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js'; import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js'; import { isMultiUserMode, userSpacePath } from '../../config/multiuser.js'; import { CASES_DIR, canAccessOwned, findSessionOrFail, getAuthUser, parseBody, validateSessionFilePath, } from '../route-helpers.js'; import type { FastifyRequest } from 'fastify'; import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js'; import { isSensitivePath } from '../sensitive-path.js'; import { SseEvent } from '../sse-events.js'; import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js'; import { FilesystemBrowseQuerySchema, FilesystemPreviewQuerySchema } from '../schemas.js'; const MIME_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', pdf: 'application/pdf', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', json: 'application/json', md: 'text/markdown', txt: 'text/plain', }; 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 { const headers = reply.getHeaders(); reply.hijack(); for (const [name, value] of Object.entries(headers)) { if (value !== undefined) { reply.raw.setHeader(name, value); } } content.on('error', (err) => { if (reply.raw.headersSent) { reply.raw.destroy(err); return; } reply.raw.statusCode = 500; reply.raw.end('Failed to read file'); }); content.pipe(reply.raw); } async function serveRawFile( reply: FastifyReply, resolvedPath: string, fileName: string, extension: string, download?: boolean ): Promise { const stat = await fs.stat(resolvedPath); const MAX_RAW_ATTACHMENT_SIZE = 50 * 1024 * 1024; // 50MB, matching file-raw / download if (stat.size > MAX_RAW_ATTACHMENT_SIZE) { reply .code(413) .send( createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_ATTACHMENT_SIZE / 1024 / 1024}MB limit)` ) ); return; } const content = createReadStream(resolvedPath); if (download || extension === 'svg') { reply.header( 'Content-Type', extension === 'svg' ? 'application/octet-stream' : MIME_TYPES[extension] || 'application/octet-stream' ); reply.header('Content-Disposition', buildContentDisposition('attachment', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); return; } reply.header('Content-Type', MIME_TYPES[extension] || 'application/octet-stream'); reply.header('Content-Disposition', buildContentDisposition('inline', fileName)); reply.header('Content-Length', stat.size); reply.header('X-Content-Type-Options', 'nosniff'); sendRawStream(reply, content); } function getAttachmentOr404( reply: FastifyReply, sessionId: string, attachmentId: string ): AttachmentRecord | undefined { const record = attachmentRegistry.get(sessionId, attachmentId); if (!record) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Attachment not found')); return undefined; } return record; } /** * COD-53 defense-in-depth: refuse to stream a record whose underlying path is * blocked by the active attachment-guard policy, even though registration * already blocks them. Guards against records that predate the guard or were * crafted to point at a sensitive file. Resolves symlinks before the check so a * record pointing at a symlink that now resolves to a sensitive target is also * caught; if the path can't be resolved (deleted/unreadable) the check still * runs on the stored path. When workspace confinement is enabled it additionally * rejects any record outside the session workspace. Returns true (and sends a * 403) when blocked. */ async function resolveServableAttachmentPath( reply: FastifyReply, record: AttachmentRecord, sessionWorkingDir?: string ): Promise { let pathToCheck = record.filePath; let resolved = false; try { pathToCheck = realpathSync(record.filePath); resolved = true; } catch { // Fall back to the stored (already realpath-resolved at registration) path. } const guard = await loadAttachmentGuardConfig(); const blocked = isBlockedAttachmentPath(pathToCheck, guard.blockedTrees) || isBlockedAttachmentPath(record.filePath, guard.blockedTrees) || (guard.confineToWorkspace && (!sessionWorkingDir || !validateSessionFilePath(sessionWorkingDir, pathToCheck))); if (blocked) { reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked')); return null; } // Serve the freshly-resolved path, not the stored one: if a path component // became a symlink after registration, the guard checked the resolved target // but streaming record.filePath would follow the symlink to a swapped file. return resolved ? pathToCheck : record.filePath; } /** * Convert a DOCX/PPTX to a single-PDF preview (LibreOffice when available) and * stream it inline. PDF/PNG and text formats don't need conversion — callers * redirect those to the raw route instead. */ async function serveConvertedPreview( reply: FastifyReply, resolvedPath: string, fileName: string, extension: string ): Promise { if (extension !== 'docx' && extension !== 'pptx') { reply .code(400) .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Preview is not supported for this file type')); return; } try { const previewPath = await getOfficePreviewPdfPath(resolvedPath, extension); if (!previewPath) { reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Document preview conversion failed')); return; } const content = await fs.readFile(previewPath); reply.header('Content-Type', 'application/pdf'); 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'); reply.send(content); } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to generate preview: ${getErrorMessage(err)}`)); } } /** Generate and stream a first-page thumbnail (PNG) for a supported attachment. */ async function serveThumbnail(reply: FastifyReply, resolvedPath: string, extension: string): Promise { const thumbnail = await generateFirstPageThumbnail(resolvedPath, extension); if (!thumbnail) { reply.code(204).send(); return; } reply.header('Content-Type', thumbnail.contentType); reply.header('Cache-Control', 'no-cache'); reply.header('X-Content-Type-Options', 'nosniff'); reply.send(thumbnail.content); } /** * Resolve a session's working dir from the live session, falling back to the * persisted record so preview/thumbnail requests keep working for a session * that has since detached. Sends a 404 and returns undefined when unknown. */ function getKnownSessionWorkingDir( ctx: SessionPort & ConfigPort, sessionId: string, reply: FastifyReply, req: FastifyRequest ): string | undefined { // Multi-user: a non-admin may only reach their OWN session's files. A foreign // (or missing) session is reported identically as 404 so existence isn't leaked. const user = getAuthUser(req); const liveSession = ctx.sessions.get(sessionId); if (liveSession && canAccessOwned(user, liveSession.owner)) return liveSession.workingDir; const stored = ctx.store.getSession(sessionId); if (stored && canAccessOwned(user, (stored as { owner?: string }).owner)) return stored.workingDir; reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`)); return undefined; } // Persisted sessions carry the private (externalPath-bearing) history under a // `__attachmentHistory` key so the list route can re-register external files. type StoredSessionWithPrivateAttachmentHistory = SessionState & { __attachmentHistory?: SessionAttachmentHistoryItem[]; }; type AttachmentHistoryRouteItem = Omit & { missing: boolean; rawUrl?: string; url?: string; previewUrl?: string; thumbnailUrl?: string; downloadUrl?: string; attachmentId?: string; }; 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 // ~/.ssh/. Probe a child path as well so the directory itself cannot be // opened and used to enumerate those filenames. return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees); } function extraConfiguredPickerRoots(): Array<{ label: string; path: string }> { const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS; if (!extraRoots) return []; return extraRoots .split(',') .map((value) => value.trim()) .filter(Boolean) .map((path, index) => ({ label: `Configured ${index + 1}`, path })); } /** * Browse roots for the requesting identity. * * Single-user mode (and multi-user admins) get the host-wide set. ⚠️ A regular * multi-user user must NOT: per-user spaces live at `/`, * which is *inside* `homedir()`, so handing out a `Home` root would let any * authenticated user browse and preview every other user's workspace. The * shared `CASES_DIR` leaks the same way, and `/mnt/d` is a broad host mount * that a multi-user deployment should not expose by default. Operators who * genuinely want a shared area can still name it in `CODEMAN_FILE_PICKER_ROOTS`, * which stays an explicit opt-in in both modes. */ function configuredFilesystemPickerRoots(req: FastifyRequest): Array<{ label: string; path: string }> { const user = getAuthUser(req); if (isMultiUserMode() && user.role !== 'admin') { return [{ label: 'My Space', path: userSpacePath(user.username) }, ...extraConfiguredPickerRoots()]; } return [ { label: 'Home', path: homedir() }, { label: 'Codeman Cases', path: CASES_DIR }, { label: 'WSL D:', path: '/mnt/d' }, ...extraConfiguredPickerRoots(), ]; } async function resolveFilesystemPickerRoots( ctx: SessionPort & ConfigPort, req: FastifyRequest, sessionId?: string ): Promise { const candidates = configuredFilesystemPickerRoots(req); if (sessionId) { const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId); // ⚠️ Ownership must be checked here, exactly as `findSessionOrFail` does for // the other session-scoped handlers in this file. Without it a multi-user // caller could pin ANOTHER user's `workingDir` as a browse root just by // passing their sessionId. Report not-found rather than forbidden so the // endpoint does not confirm that a session id exists. if (!session || !canAccessOwned(getAuthUser(req), (session as { owner?: string }).owner)) { throw Object.assign(new Error(`Session ${sessionId} not found`), { statusCode: 404, body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`), }); } candidates.unshift({ label: 'Current Folder', path: session.workingDir }); } const guard = await loadAttachmentGuardConfig(); const roots: FilesystemBrowseRoot[] = []; const seen = new Set(); for (const candidate of candidates) { if (!isAbsolute(candidate.path)) continue; try { const resolved = realpathSync(candidate.path); if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue; const stat = await fs.stat(resolved); if (!stat.isDirectory()) continue; seen.add(resolved); roots.push({ label: candidate.label, path: resolved }); } catch { // Optional roots (for example /mnt/d on non-WSL hosts) are omitted. } } 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, req: FastifyRequest, requestedPath: string | undefined, sessionId?: string ): Promise { const roots = await resolveFilesystemPickerRoots(ctx, req, 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`; } function getSessionAttachmentHistory( ctx: SessionPort & ConfigPort, sessionId: string, req: FastifyRequest ): { workingDir: string; history: SessionAttachmentHistoryItem[] } | undefined { const user = getAuthUser(req); const liveSession = ctx.sessions.get(sessionId); if (liveSession) { if (!canAccessOwned(user, liveSession.owner)) return undefined; return { workingDir: liveSession.workingDir, history: liveSession.getAttachmentHistoryForPersist() ?? liveSession.attachmentHistory ?? [], }; } const stored = ctx.store.getSession(sessionId) as StoredSessionWithPrivateAttachmentHistory | undefined; if (!stored || !canAccessOwned(user, (stored as { owner?: string }).owner)) return undefined; return { workingDir: stored.workingDir, history: stored.__attachmentHistory ?? stored.attachmentHistory ?? [], }; } // History item for a file detected inside the workspace: re-stat for live // size/mtime and resolve preview/thumbnail/raw routes off the relative path. async function buildDetectedAttachmentRouteItem( sessionId: string, workingDir: string, item: SessionAttachmentHistoryItem ): Promise { const safe = sanitizeAttachmentHistoryItem(item); if (!item.relativePath) { return { ...safe, missing: true }; } const validated = validateSessionFilePath(workingDir, item.relativePath); if (!validated) { return { ...safe, missing: true }; } let size = item.size; let mtimeMs = item.mtimeMs; try { const stat = await fs.stat(validated.resolvedPath); size = stat.size; mtimeMs = stat.mtimeMs ?? mtimeMs; } catch { return { ...safe, missing: true }; } const encodedPath = encodeURIComponent(item.relativePath); const rawUrl = `/api/sessions/${sessionId}/file-raw?path=${encodedPath}`; const previewUrl = item.extension === 'docx' || item.extension === 'pptx' ? `/api/sessions/${sessionId}/file-preview?path=${encodedPath}` : rawUrl; const thumbnailUrl = isSupportedAttachmentExtension(item.extension) ? buildFileThumbnailRoute(sessionId, item.relativePath) : undefined; return { ...safe, size, mtimeMs, missing: false, rawUrl, url: rawUrl, previewUrl, thumbnailUrl, downloadUrl: appendDownloadFlag(rawUrl), }; } // History item for an explicitly published external file: re-register it to mint // a fresh id + by-id routes (the guard runs again), or mark it missing. async function buildExternalAttachmentRouteItem( sessionId: string, item: SessionAttachmentHistoryItem, sessionWorkingDir?: string ): Promise { const safe = sanitizeAttachmentHistoryItem(item); if (!item.externalPath) { return { ...safe, missing: true }; } try { const event = await registerExternalAttachment(sessionId, item.externalPath, { sessionWorkingDir }); return { ...safe, fileName: event.fileName, extension: event.extension, attachmentType: event.attachmentType, size: event.size, missing: false, attachmentId: event.attachmentId, rawUrl: event.rawUrl, url: event.rawUrl, previewUrl: event.previewUrl, thumbnailUrl: event.thumbnailUrl, downloadUrl: appendDownloadFlag(event.rawUrl), }; } catch (err) { if (err instanceof AttachmentRegistrationError) { return { ...safe, missing: true }; } throw err; } } export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void { // Lazy filesystem listing for the Link Existing and mobile input path pickers. app.get('/api/filesystem/browse', async (req, reply): Promise> => { const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query); const { candidatePath, resolvedPath, roots, matchingRoot, blockedTrees } = await resolveFilesystemPickerPath( ctx, req, requestedPath, sessionId ); if (isBlockedPickerPath(resolvedPath, blockedTrees, true)) { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked'); } try { const stat = await fs.stat(resolvedPath); if (!stat.isDirectory()) { reply.code(400); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory'); } } catch { reply.code(404); return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`); } let dirEntries; try { dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true }); } catch { reply.code(403); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read'); } dirEntries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); const entries: FilesystemBrowseEntry[] = []; let truncated = false; for (const entry of dirEntries) { if (entry.name.startsWith('.')) continue; if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) { truncated = true; break; } const visiblePath = join(candidatePath, entry.name); let targetPath: string; try { targetPath = realpathSync(visiblePath); } catch { continue; } const targetRoot = findMatchingPickerRoot(roots, targetPath); if (!targetRoot || containsHiddenPickerSegment(targetRoot.path, targetPath)) continue; let type: FilesystemBrowseEntry['type']; let size: number | undefined; const symlink = entry.isSymbolicLink(); if (entry.isDirectory()) { type = 'directory'; } else if (entry.isFile()) { type = 'file'; } else if (symlink) { try { const targetStat = await fs.stat(targetPath); type = targetStat.isDirectory() ? 'directory' : 'file'; if (type === 'file') size = targetStat.size; } catch { continue; } } else { continue; } if (isBlockedPickerPath(targetPath, blockedTrees, type === 'directory')) continue; if (type === 'file' && size === undefined) { try { size = (await fs.stat(targetPath)).size; } catch { // 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, previewKind: type === 'file' ? getFilesystemPreviewKind(entry.name) : undefined, }); } const parentCandidate = resolve(candidatePath, '..'); let parent: string | null = null; if (candidatePath !== matchingRoot.path) { try { const resolvedParent = realpathSync(parentCandidate); if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate; } catch { // A concurrently removed parent simply disables upward navigation. } } return { success: true, data: { path: candidatePath, parent, root: matchingRoot.path, roots, entries, truncated, }, }; }); // Inline preview for files selected through the root-confined filesystem picker. app.get('/api/filesystem/preview', { compress: false }, async (req, reply): Promise => { const { path: requestedPath, sessionId } = parseBody(FilesystemPreviewQuerySchema, req.query); const { candidatePath, resolvedPath, blockedTrees } = await resolveFilesystemPickerPath( ctx, req, 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 }; const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; const session = findSessionOrFail(ctx, id, req); const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; const workingDir = session.workingDir; // Default excludes - large/generated directories const excludeDirs = new Set([ '.git', 'node_modules', 'dist', 'build', '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.venv', 'venv', '.tox', 'target', 'vendor', ]); interface FileTreeNode { name: string; path: string; type: 'file' | 'directory'; size?: number; extension?: string; children?: FileTreeNode[]; } let totalFiles = 0; let totalDirectories = 0; let truncated = false; const maxFiles = 5000; const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { truncated = true; return []; } try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const nodes: FileTreeNode[] = []; // Sort: directories first, then alphabetically entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); for (const entry of entries) { if (totalFiles + totalDirectories > maxFiles) { truncated = true; break; } // Skip hidden files unless requested if (!includeHidden && entry.name.startsWith('.')) continue; // Skip excluded directories if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; const fullPath = join(dirPath, entry.name); const relativePath = fullPath.slice(workingDir.length + 1); if (entry.isDirectory()) { totalDirectories++; const children = await scanDirectory(fullPath, currentDepth + 1); nodes.push({ name: entry.name, path: relativePath, type: 'directory', children, }); } else { totalFiles++; const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; let size: number | undefined; try { const stat = await fs.stat(fullPath); size = stat.size; } catch { // Skip if can't stat } nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext, }); } } return nodes; } catch { // Can't read directory (permission denied, etc.) return []; } }; const tree = await scanDirectory(workingDir, 1); return { success: true, data: { root: workingDir, tree, totalFiles, totalDirectories, truncated, }, }; }); // Get file content for preview (File Browser) app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); } // Validate path is within working directory (security: resolve symlinks to prevent traversal) const validated = validateSessionFilePath(session.workingDir, filePath); if (!validated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } const { resolvedPath } = validated; try { const stat = await fs.stat(resolvedPath); // Classify by extension. Known media types render with a dedicated player; // other known-binary types are flagged so the client offers a download // affordance instead of trying to decode the bytes as text. Matches the // breadth of formats the attachments viewer renders (image/audio/video/pdf) // so the file viewer can open the same files. const ext = filePath.split('.').pop()?.toLowerCase() || ''; const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); const videoExts = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogv']); const audioExts = new Set(['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus']); const otherBinaryExts = new Set([ 'pdf', 'zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar', 'exe', 'dll', 'so', 'dylib', 'bin', 'wasm', 'class', 'o', 'a', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'xlsx', 'xls', 'doc', 'docx', 'ppt', 'pptx', 'odt', 'ods', 'odp', 'avi', 'mkv', 'wmv', 'flv', ]); const mediaType = imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : audioExts.has(ext) ? 'audio' : null; const fileRawUrl = `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`; if (raw === 'true' || mediaType || otherBinaryExts.has(ext)) { // Return metadata for media/binary files (no text body) return { success: true, data: { path: filePath, size: stat.size, type: mediaType ?? 'binary', extension: ext, url: fileRawUrl, }, }; } // Validate file size before reading (DoS protection - prevent memory exhaustion) const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (stat.size > MAX_TEXT_FILE_SIZE) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)` ); } // Read as raw bytes so we can sniff for binary content before decoding. An // unrecognized extension (none at all, or a format not listed above) that // is actually binary would otherwise be dumped to the viewer as UTF-8 // mojibake; a NUL byte in the first 8KB is a reliable binary signal that // (unlike a static extension list) catches arbitrary binary formats. const fileBuffer = await fs.readFile(resolvedPath); const buf = Buffer.isBuffer(fileBuffer) ? fileBuffer : Buffer.from(String(fileBuffer)); const sniffLength = Math.min(buf.length, 8192); let looksBinary = false; for (let i = 0; i < sniffLength; i++) { if (buf[i] === 0) { looksBinary = true; break; } } if (looksBinary) { return { success: true, data: { path: filePath, size: stat.size, type: 'binary', extension: ext, url: fileRawUrl, }, }; } // Read text file with line limit (bounded to prevent DoS) const MAX_LINES_LIMIT = 10000; const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT); const content = buf.toString('utf-8'); const allLines = content.split('\n'); const truncatedContent = allLines.length > maxLines; const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content; return { success: true, data: { path: filePath, content: displayContent, size: stat.size, totalLines: allLines.length, truncated: truncatedContent, extension: ext, }, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); } }); // Serve raw file content (for images/binary files) app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath, download } = req.query as { path?: string; download?: string }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } // Validate path is within working directory (security: resolve symlinks to prevent traversal) const validated = validateSessionFilePath(session.workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } const { resolvedPath } = validated; try { // Validate file size before reading (DoS protection - prevent memory exhaustion) const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files const stat = await fs.stat(resolvedPath); if (stat.size > MAX_RAW_FILE_SIZE) { reply .code(400) .send( createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)` ) ); return; } const ext = filePath.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', bmp: 'image/bmp', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', m4v: 'video/mp4', ogv: 'video/ogg', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', oga: 'audio/ogg', opus: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac', pdf: 'application/pdf', json: 'application/json', }; const content = await fs.readFile(resolvedPath); const rawBasename = filePath!.split('/').pop() || 'download'; // Sanitize filename for Content-Disposition header (prevent header injection) const basename = rawBasename.replace(/["\\\r\n]/g, '_'); if (download === 'true' || ext === 'svg') { reply.raw.writeHead(200, { 'Content-Type': ext === 'svg' ? 'application/octet-stream' : mimeTypes[ext] || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${basename}"`, 'Content-Length': content.length, 'X-Content-Type-Options': 'nosniff', }); reply.raw.end(content); return; } reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); reply.header('X-Content-Type-Options', 'nosniff'); reply.send(content); } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); } }); // ===== Live external attachments ===== // Register an explicit, live external file (absolute host path) as an // attachment with a stable id so browser requests never carry arbitrary // paths. Registration enforces the COD-53 attachment-guard policy. Serving is // by id via the /raw route below; document previews/thumbnails and the // attachment-history list are layered on separately. app.post('/api/sessions/:id/attachments', async (req, reply) => { const { id } = req.params as { id: string }; const session = findSessionOrFail(ctx, id, req); const body = (req.body || {}) as { path?: string }; if (!body.path || typeof body.path !== 'string') { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing attachment path')); return; } try { const event = await registerExternalAttachment(id, body.path, { sessionWorkingDir: session.workingDir }); ctx.broadcast(SseEvent.AttachmentDetected, event); return { success: true, data: event }; } catch (err) { if (err instanceof AttachmentRegistrationError) { reply.code(err.statusCode).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, err.message)); return; } return reply .code(500) .send( createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to register attachment: ${getErrorMessage(err)}`) ); } }); // List a session's attachment history (live session or persisted), resolving // each entry to current metadata + routes. External entries are re-registered. app.get('/api/sessions/:id/attachments', async (req, reply) => { const { id } = req.params as { id: string }; const sessionHistory = getSessionAttachmentHistory(ctx, id, req); if (!sessionHistory) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`)); return; } const items = await Promise.all( sessionHistory.history.map((item) => (item.source === 'external' ? buildExternalAttachmentRouteItem(id, item, sessionHistory.workingDir) : buildDetectedAttachmentRouteItem(id, sessionHistory.workingDir, item) ).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true })) ) ); return { success: true, data: { items, count: items.length, }, }; }); // Metadata poll for a single registered attachment (re-stats for live // size/mtime as the underlying file is rewritten). app.get('/api/sessions/:id/attachments/:attachmentId', async (req, reply) => { const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); if (!workingDir) return; const record = getAttachmentOr404(reply, id, attachmentId); if (!record) return; if (!(await resolveServableAttachmentPath(reply, record, workingDir))) return; const event = attachmentRecordToEvent(record); let size = record.size; let mtimeMs = record.mtimeMs; try { const stat = await fs.stat(record.filePath); size = stat.size; mtimeMs = stat.mtimeMs ?? mtimeMs; } catch { // File temporarily unavailable mid-write — keep cached values. } return { success: true, data: { path: record.fileName, size, mtimeMs, type: record.attachmentType, extension: record.extension, url: event.rawUrl, previewUrl: event.previewUrl, thumbnailUrl: event.thumbnailUrl, attachmentId: record.attachmentId, fileName: record.fileName, }, }; }); // Serve the raw bytes of a registered attachment by id. Re-checks the // attachment-guard policy on every request (defense-in-depth) before streaming. app.get('/api/sessions/:id/attachments/:attachmentId/raw', async (req, reply) => { const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const { download } = req.query as { download?: string }; const session = findSessionOrFail(ctx, id, req); const record = getAttachmentOr404(reply, id, attachmentId); if (!record) return; const servePath = await resolveServableAttachmentPath(reply, record, session.workingDir); if (!servePath) return; try { await serveRawFile(reply, servePath, record.fileName, record.extension, download === 'true'); } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); } }); // Serve a converted PDF preview of a registered attachment by id. Office docs // convert server-side; PDF/PNG/text redirect to the raw route. app.get('/api/sessions/:id/attachments/:attachmentId/preview', async (req, reply) => { const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); if (!workingDir) return; const record = getAttachmentOr404(reply, id, attachmentId); if (!record) return; const servePath = await resolveServableAttachmentPath(reply, record, workingDir); if (!servePath) return; // Only Office formats need server-side conversion; PDF/PNG and text formats // (md/txt) preview directly from their raw bytes. if (record.extension !== 'docx' && record.extension !== 'pptx') { reply.redirect(`/api/sessions/${id}/attachments/${encodeURIComponent(attachmentId)}/raw`); return; } await serveConvertedPreview(reply, servePath, record.fileName, record.extension); }); // Serve a first-page thumbnail of a registered attachment by id. app.get('/api/sessions/:id/attachments/:attachmentId/thumbnail', async (req, reply) => { const { id, attachmentId } = req.params as { id: string; attachmentId: string }; const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); if (!workingDir) return; const record = getAttachmentOr404(reply, id, attachmentId); if (!record) return; const servePath = await resolveServableAttachmentPath(reply, record, workingDir); if (!servePath) return; await serveThumbnail(reply, servePath, record.extension); }); // Serve converted document previews for a workspace-relative path. DOCX/PPTX // are converted to PDF via LibreOffice; PDF/PNG/text preview through file-raw. app.get('/api/sessions/:id/file-preview', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath } = req.query as { path?: string }; const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); if (!workingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } const { resolvedPath } = validated; const ext = filePath.split('.').pop()?.toLowerCase() || ''; if (ext !== 'docx' && ext !== 'pptx') { reply.redirect(`/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`); return; } await serveConvertedPreview(reply, resolvedPath, filePath, ext); }); // Serve a first-page thumbnail for a workspace-relative path. app.get('/api/sessions/:id/file-thumbnail', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath } = req.query as { path?: string }; const workingDir = getKnownSessionWorkingDir(ctx, id, reply, req); if (!workingDir) return; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } const validated = validateSessionFilePath(workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } const ext = filePath.split('.').pop()?.toLowerCase() || ''; if (!isSupportedAttachmentExtension(ext)) { reply .code(400) .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Thumbnail is not supported for this file type')); return; } await serveThumbnail(reply, validated.resolvedPath, ext); }); // Stream file content via tail -f (SSE endpoint) app.get('/api/sessions/:id/tail-file', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath, lines } = req.query as { path?: string; lines?: string }; const session = findSessionOrFail(ctx, id, req); if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } // Set up SSE headers reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); // Track stream for cleanup const streamRef: { id?: string } = {}; // Create the file stream const result = await fileStreamManager.createStream({ sessionId: id, filePath, workingDir: session.workingDir, lines: lines ? parseInt(lines, 10) : undefined, onData: (data) => { // Send data as SSE event reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`); }, onEnd: () => { reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`); reply.raw.end(); }, onError: (error) => { reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`); }, }); if (!result.success) { reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`); reply.raw.end(); return; } streamRef.id = result.streamId; // Notify client of successful connection reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`); // Handle client disconnect req.raw.on('close', () => { if (streamRef.id) { fileStreamManager.closeStream(streamRef.id); } }); }); // Close a file stream. Returns { closed } rather than { success: closed } — // a top-level `success` key would collide with the envelope discriminator // (the preSerialization hook would pass `{success:false}` through as a // malformed error envelope instead of wrapping it). app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => { const { id, streamId } = req.params as { id: string; streamId: string }; findSessionOrFail(ctx, id, req); // Validates session exists const closed = fileStreamManager.closeStream(streamId); return { closed }; }); // Session-scoped file download. // Uses the same realpath-based workspace boundary as file preview/raw routes; // the shared sensitive-path blocklist (../sensitive-path.js, also used by the // attachment guard) remains defense-in-depth, not the primary boundary. app.get('/api/download', async (req, reply) => { const { path: filePath, sessionId } = req.query as { path?: string; sessionId?: string }; if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } if (!sessionId) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing sessionId parameter')); return; } const session = findSessionOrFail(ctx, sessionId, req); const validated = validateSessionFilePath(session.workingDir, filePath); if (!validated) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found')); return; } const { resolvedPath } = validated; // Check sensitive path blocklist if (isSensitivePath(resolvedPath)) { reply.code(403).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this file is blocked')); return; } try { const stat = await fs.stat(resolvedPath); if (!stat.isFile()) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is not a file')); return; } // 50MB size limit const MAX_DOWNLOAD_SIZE = 50 * 1024 * 1024; if (stat.size > MAX_DOWNLOAD_SIZE) { reply .code(400) .send( createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > 50MB limit)` ) ); return; } const ext = filePath.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', pdf: 'application/pdf', json: 'application/json', txt: 'text/plain', md: 'text/markdown', csv: 'text/csv', xml: 'application/xml', zip: 'application/zip', gz: 'application/gzip', tar: 'application/x-tar', }; const filename = pathBasename(resolvedPath); const content = await fs.readFile(resolvedPath); // Bypass Fastify compression — write directly to raw response reply.raw.writeHead(200, { 'Content-Type': mimeTypes[ext] || 'application/octet-stream', 'Content-Disposition': `attachment; filename="${filename}"`, 'Content-Length': content.length, }); reply.raw.end(content); return; } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); } }); }