import { type Application, type Request, type Response } from 'express'; import { existsSync, readdirSync, statSync } from 'fs'; import { resolve, sep } from 'path'; import { Repository } from '../db/repository.js'; import { logger } from '../logger.js'; import { parseTaskId } from './validation.js'; import { canViewTask } from './local-api-helpers.js'; export function mountSubtaskFilesApi(app: Application, repo: Repository): void { // NOTE: listing MUST be registered before the wildcard route app.get('/api/local/tasks/:id/subtasks/:jobId/files', async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.id); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const jobId = req.params.jobId; const viewer = (req.user as Express.User | undefined) ?? undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task)) return; // jobId で直接取得(孫タスクにも対応) const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined); if (!subJob || !subJob.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; } // タスクのワークスペース配下であることを確認 if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) { res.status(404).json({ error: 'Subtask not found' }); return; } const basePath = resolve(subJob.worktreePath); const categories: Record = {}; for (const dir of ['output', 'logs', 'input']) { const dirPath = resolve(basePath, dir); if (!existsSync(dirPath)) continue; const dirFiles = readdirSync(dirPath, { recursive: true }) .map(f => String(f)) .filter(f => !statSync(resolve(dirPath, f)).isDirectory()); if (dirFiles.length > 0) categories[dir] = dirFiles; } // 後方互換: files は output/ のファイル一覧 res.json({ files: categories['output'] ?? [], categories }); } catch (err) { logger.error(`Subtask file list API error: ${err}`); res.status(500).json({ error: 'Failed to list subtask files' }); } }); app.get('/api/local/tasks/:id/subtasks/:jobId/files/*', async (req: Request, res: Response) => { try { const taskId = parseTaskId(req.params.id); if (taskId === null) { res.status(400).json({ error: 'Invalid task ID' }); return; } const jobId = req.params.jobId; const filePath = req.params[0]; const viewer = (req.user as Express.User | undefined) ?? undefined; const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined); if (!canViewTask(req, res, task)) return; // jobId で直接取得(孫タスクにも対応) const subJob = await repo.getJob(jobId, viewer ? { viewer } : undefined); if (!subJob || !subJob.worktreePath) { res.status(404).json({ error: 'Subtask not found' }); return; } // タスクのワークスペース配下であることを確認 if (task!.workspacePath && !subJob.worktreePath.startsWith(task!.workspacePath)) { res.status(404).json({ error: 'Subtask not found' }); return; } const base = resolve(subJob.worktreePath); const resolved = resolve(base, filePath); // Require the trailing separator so a sibling like `-x` cannot pass // the prefix check; allow the base dir itself. if (resolved !== base && !resolved.startsWith(base + sep)) { res.status(403).json({ error: 'Access denied' }); return; } if (!existsSync(resolved)) { res.status(404).json({ error: 'File not found' }); return; } const stat = statSync(resolved); if (stat.isDirectory()) { const dirFiles = readdirSync(resolved); res.json({ files: dirFiles }); return; } res.sendFile(resolved); } catch (err) { logger.error(`Subtask files API error: ${err}`); res.status(500).json({ error: 'Failed to fetch subtask file' }); } }); }