175 lines
7.3 KiB
TypeScript
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' });
|
|
}
|
|
});
|
|
}
|