mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 16:39:42 +02:00
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>
372 lines
12 KiB
TypeScript
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 };
|
|
});
|
|
}
|