Files
Codeman/src/web/routes/file-routes.ts
T
arkonandClaude Opus 4.6 a448983be3 refactor: pass 2 — extract shared helpers and simplify patterns
app.js:
- Add _clearTimer() helper replacing 11 inline clearTimeout patterns
- Add _isStaleSelect() helper for generation check + cleanup
- Replace 11 keyboard shortcut if-blocks with data-driven lookup table
- Extract _cleanupPreviousSession() from selectSession() (~75 lines)
- Extract _resetAllAppState() from handleInit() (~75 lines)

tmux-manager:
- Extract buildEnvExports() eliminating duplication in createSession/respawnPane
- Extract buildPathExport() for CLI path resolution
- Extract _configureOpenCode() for OpenCode setup

routes:
- Add readJsonConfig() to route-helpers, replacing 5 inline JSON-read patterns
- Add validateSessionFilePath() to route-helpers, replacing 2 identical path
  traversal validation blocks in file-routes

session-auto-ops:
- Convert executeWhenIdle() from 8 positional params to options object
- Extract validateThreshold() for shared compact/clear validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 23:32:28 +01:00

372 lines
12 KiB
TypeScript

/**
* @fileoverview File browser and streaming routes.
* Provides directory listing, file content preview, raw file serving, and tail streaming.
*/
import { FastifyInstance } from 'fastify';
import { join } from 'node:path';
import fs from 'node:fs/promises';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { fileStreamManager } from '../../file-stream-manager.js';
import { findSessionOrFail, validateSessionFilePath } from '../route-helpers.js';
import type { SessionPort } from '../ports/index.js';
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort): void {
// 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);
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<FileTreeNode[]> => {
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);
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);
// Check if it's a binary/media file
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const binaryExts = new Set([
'png',
'jpg',
'jpeg',
'gif',
'webp',
'ico',
'svg',
'bmp',
'mp4',
'webm',
'mov',
'avi',
'mp3',
'wav',
'ogg',
'pdf',
'zip',
'tar',
'gz',
'exe',
'dll',
'so',
'woff',
'woff2',
'ttf',
'eot',
]);
const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']);
const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']);
if (raw === 'true' || binaryExts.has(ext)) {
// Return metadata for binary files
return {
success: true,
data: {
path: filePath,
size: stat.size,
type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary',
extension: ext,
url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`,
},
};
}
// 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 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 = await fs.readFile(resolvedPath, '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 } = req.query as { path?: string };
const session = findSessionOrFail(ctx, id);
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<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml',
ico: 'image/x-icon',
bmp: 'image/bmp',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
pdf: 'application/pdf',
json: 'application/json',
};
const content = await fs.readFile(resolvedPath);
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
reply
.code(500)
.send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
// 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);
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
app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => {
const { id, streamId } = req.params as { id: string; streamId: string };
findSessionOrFail(ctx, id); // Validates session exists
const closed = fileStreamManager.closeStream(streamId);
return { success: closed };
});
}