import express, { type Application, type Request, type Response } from 'express'; import { mkdirSync, readdirSync, statSync, readFileSync, writeFileSync } from 'fs'; import { join, extname } from 'path'; import { Repository, localTaskRepoName } from '../db/repository.js'; import { logger } from '../logger.js'; import { parseTaskId } from './validation.js'; import { ensurePathWithin, serializeLocalFileEntry, checkTaskOwnership, canViewTask } from './local-api-helpers.js'; export function mountLocalFilesApi(app: Application, repo: Repository): void { app.get('/api/local/tasks/:taskId/files', async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.taskId); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task)) return; if (!task?.workspacePath) { res.status(404).json({ error: 'Workspace not found' }); return; } const section = String(req.query.section ?? 'input'); if (!['workspace', 'input', 'output', 'logs'].includes(section)) { res.status(400).json({ error: 'section must be workspace, input, output, or logs' }); return; } const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, ''); const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); mkdirSync(rootDir, { recursive: true }); const dirPath = ensurePathWithin(rootDir, relativeDir); const entries = readdirSync(dirPath, { withFileTypes: true }).map((entry) => { const stat = statSync(join(dirPath, entry.name)); return serializeLocalFileEntry(relativeDir, entry.name, entry.isDirectory(), stat.size, stat.mtime); }); res.json({ basePath: section, path: relativeDir, entries }); } catch (err) { logger.error(`Local files list API error: ${err}`); res.status(500).json({ error: 'Failed to list files' }); } }); app.get('/api/local/tasks/:taskId/files/content', async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.taskId); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task)) return; if (!task?.workspacePath) { res.status(404).json({ error: 'Workspace not found' }); return; } const section = String(req.query.section ?? 'input'); if (!['workspace', 'input', 'output', 'logs'].includes(section)) { res.status(400).json({ error: 'section must be workspace, input, output, or logs' }); return; } const relativePath = String(req.query.path ?? '').replace(/^\/+/, ''); if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; } const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); const filePath = ensurePathWithin(rootDir, relativePath); const stat = statSync(filePath); if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; } res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(readFileSync(filePath, 'utf-8')); } catch (err) { logger.error(`Local file content API error: ${err}`); res.status(500).json({ error: 'Failed to read file' }); } }); app.get('/api/local/tasks/:taskId/files/raw', async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.taskId); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task)) return; if (!task?.workspacePath) { res.status(404).json({ error: 'Workspace not found' }); return; } const section = String(req.query.section ?? 'input'); if (!['workspace', 'input', 'output', 'logs'].includes(section)) { res.status(400).json({ error: 'section must be workspace, input, output, or logs' }); return; } const relativePath = String(req.query.path ?? '').replace(/^\/+/, ''); if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; } const rootDir = section === 'workspace' ? task.workspacePath : join(task.workspacePath, section); const filePath = ensurePathWithin(rootDir, relativePath); const stat = statSync(filePath); if (!stat.isFile()) { res.status(400).json({ error: 'path must point to a file' }); return; } res.type(extname(filePath) || 'application/octet-stream'); res.send(readFileSync(filePath)); } catch (err) { logger.error(`Local file raw API error: ${err}`); res.status(500).json({ error: 'Failed to read raw file' }); } }); app.put('/api/local/tasks/:taskId/files/content', express.json(), async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.taskId); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const viewer = req.user as Express.User | undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!checkTaskOwnership(req, res, task)) return; if (!task?.workspacePath) { res.status(404).json({ error: 'Workspace not found' }); return; } const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId); if (latestJob && ['running', 'dispatching'].includes(latestJob.status)) { res.status(409).json({ error: 'Cannot edit files while job is running' }); return; } const section = String(req.body?.section ?? ''); if (section !== 'output') { res.status(400).json({ error: 'Only output files can be edited' }); return; } const relativePath = String(req.body?.path ?? '').replace(/^\/+/, ''); if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; } const content = req.body?.content; if (typeof content !== 'string') { res.status(400).json({ error: 'content is required' }); return; } // PUT (inline edit) is output-only; section is narrowed to 'output' above. const rootDir = join(task.workspacePath, section); const filePath = ensurePathWithin(rootDir, relativePath); writeFileSync(filePath, content, 'utf-8'); res.json({ ok: true }); } catch (err) { const message = err instanceof Error ? err.message : String(err); if (message === 'Path escapes workspace') { res.status(400).json({ error: message }); return; } logger.error(`Local file update API error: ${err}`); res.status(500).json({ error: 'Failed to update file' }); } }); }