103 lines
4.1 KiB
TypeScript
103 lines
4.1 KiB
TypeScript
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<string, string[]> = {};
|
|
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 `<base>-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' });
|
|
}
|
|
});
|
|
}
|