maestro/src/bridge/share-api.ts
2026-06-03 05:08:00 +00:00

175 lines
7.3 KiB
TypeScript

import express, { Request, Response } from 'express';
import { readdirSync, statSync, readFileSync, mkdirSync } from 'fs';
import { join, resolve, sep, extname } from 'path';
import { Repository, localTaskRepoName } from '../db/repository.js';
import { logger } from '../logger.js';
import { parseTaskId } from './validation.js';
import { checkTaskOwnership } from './local-api-helpers.js';
function ensurePathWithin(baseDir: string, requestedPath: string): string {
const resolvedBase = resolve(baseDir);
const resolvedPath = resolve(baseDir, requestedPath);
if (!resolvedPath.startsWith(resolvedBase + sep) && resolvedPath !== resolvedBase) {
throw new Error('Path escapes workspace');
}
return resolvedPath;
}
function sanitizeTaskForPublic(task: Record<string, unknown>): Record<string, unknown> {
const { ownerId, workspacePath, body, ...safe } = task;
return safe;
}
export function mountShareApi(app: express.Application, repo: Repository): void {
// ── 公開エンドポイント(認証不要) ──
app.get('/api/shared/:token', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
res.json({ task: sanitizeTaskForPublic(task as unknown as Record<string, unknown>) });
} catch (err) {
logger.error(`Shared task API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch shared task' });
}
});
app.get('/api/shared/:token/comments', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const comments = await repo.listLocalTaskComments(task.id);
res.json({ comments });
} catch (err) {
logger.error(`Shared comments API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch comments' });
}
});
app.get('/api/shared/:token/files', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativeDir = String(req.query.path ?? '').replace(/^\/+/, '').replace(/\/+$/, '');
const rootDir = join(task.workspacePath, 'output');
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 {
name: entry.name,
path: relativeDir ? `${relativeDir}/${entry.name}` : entry.name,
kind: entry.isDirectory() ? 'directory' : 'file',
size: stat.size,
modifiedAt: stat.mtime.toISOString(),
};
});
res.json({ basePath: 'output', path: relativeDir, entries });
} catch (err) {
logger.error(`Shared files API error: ${err}`);
res.status(500).json({ error: 'Failed to list files' });
}
});
app.get('/api/shared/:token/files/content', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
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(`Shared file content API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/files/raw', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task || !task.workspacePath) { res.status(404).json({ error: 'Not found' }); return; }
const relativePath = String(req.query.path ?? '').replace(/^\/+/, '');
if (!relativePath) { res.status(400).json({ error: 'path is required' }); return; }
const rootDir = join(task.workspacePath, 'output');
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(`Shared file raw API error: ${err}`);
res.status(500).json({ error: 'Failed to read file' });
}
});
app.get('/api/shared/:token/subtasks/activities', async (req: Request, res: Response) => {
try {
const task = await repo.getLocalTaskByShareToken(req.params.token);
if (!task) { res.status(404).json({ error: 'Not found' }); return; }
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(task.id), task.id);
if (!latestJob) { res.json({ subtasks: [] }); return; }
const subJobs = await repo.getSubJobs(latestJob.id);
const subtasks = subJobs.map(job => ({
jobId: job.id,
issueNumber: job.issueNumber,
status: job.status,
currentMovement: job.currentMovement ?? null,
currentActivity: job.currentActivity ?? null,
activityLog: '',
}));
res.json({ subtasks });
} catch (err) {
logger.error(`Shared subtask activities API error: ${err}`);
res.status(500).json({ error: 'Failed to fetch subtask activities' });
}
});
// ── 認証付きエンドポイント ──
app.post('/api/local/tasks/:taskId/share', 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;
const shareToken = await repo.shareLocalTask(taskId);
res.json({ shareToken, shareUrl: `/ui/shared/${shareToken}` });
} catch (err) {
logger.error(`Share task API error: ${err}`);
res.status(500).json({ error: 'Failed to share task' });
}
});
app.delete('/api/local/tasks/:taskId/share', 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;
await repo.unshareLocalTask(taskId);
res.json({ ok: true });
} catch (err) {
logger.error(`Unshare task API error: ${err}`);
res.status(500).json({ error: 'Failed to unshare task' });
}
});
}