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, isPathEscapeError, serializeLocalFileEntry, checkTaskOwnership, canViewTask, setUntrustedFileResponseHeaders } from './local-api-helpers.js'; export interface LocalFilesApiOptions { /** Whether the auth subsystem is wired. When false (no-auth single-user * deployment) there is no req.user, so the sole local operator owns every * task and is allowed to open their own generated HTML with trusted=1. * Defaults to true (owner identity comes from req.user). */ authActive?: boolean; } export function mountLocalFilesApi( app: Application, repo: Repository, opts: LocalFilesApiOptions = {}, ): void { const authActive = opts.authActive ?? true; 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) { if (isPathEscapeError(err)) { res.status(400).json({ error: 'Path escapes workspace' }); return; } 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; } setUntrustedFileResponseHeaders(res); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.send(readFileSync(filePath, 'utf-8')); } catch (err) { if (isPathEscapeError(err)) { res.status(400).json({ error: 'Path escapes workspace' }); return; } 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; } // trusted=1 drops the CSP sandbox so the owner's own generated HTML can // run on the app origin. STRICTLY owner-only — self-XSS at worst: // - org/public visibility lets other users VIEW the task, but serving // someone else's HTML unsandboxed here would be stored XSS against // the viewer; // - admins are excluded too: another user's HTML running in an ADMIN // session would be a user→admin privilege-escalation lure. // No-auth single-user mode has no req.user; the sole operator owns every // task, so they are the owner for this purpose (self-XSS only — there is // no second principal to attack). const trustedAllowed = authActive ? !!viewer && task.ownerId != null && viewer.id === task.ownerId : true; const trustedHtml = req.query.trusted === '1' && /\.html?$/i.test(filePath) && trustedAllowed; if (!trustedHtml) { setUntrustedFileResponseHeaders(res); } res.type(extname(filePath) || 'application/octet-stream'); res.send(readFileSync(filePath)); } catch (err) { if (isPathEscapeError(err)) { res.status(400).json({ error: 'Path escapes workspace' }); return; } 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) { if (isPathEscapeError(err)) { res.status(400).json({ error: 'Path escapes workspace' }); return; } logger.error(`Local file update API error: ${err}`); res.status(500).json({ error: 'Failed to update file' }); } }); }