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): Record { 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) }); } 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' }); } }); }