278 lines
13 KiB
TypeScript
278 lines
13 KiB
TypeScript
import express, { Router } from 'express';
|
||
import { dirname } from 'node:path';
|
||
import type { Repository } from '../db/repository.js';
|
||
import { spaceWorkspaceDir } from '../spaces/paths.js';
|
||
import { scaffoldCaseSpace, removeSpaceDirs } from '../spaces/scaffold.js';
|
||
import { canManageSpace } from './visibility.js';
|
||
import { registerSpaceFilesRoutes } from './space-files-api.js';
|
||
import { registerSpaceMembersRoutes } from './space-members-api.js';
|
||
import { registerSpaceCalendarRoutes } from './space-calendar-api.js';
|
||
import { registerSpaceInviteRoutes } from './space-invite-api.js';
|
||
import { registerSpaceAppShareRoutes } from './space-app-share-api.js';
|
||
import { registerSpaceToolPolicyRoutes } from './space-tool-policy-api.js';
|
||
import { registerSpacePythonPackagesRoutes } from './space-python-packages-api.js';
|
||
import { registerSpaceWebhooksRoutes } from './space-webhooks-api.js';
|
||
import { viewerOf } from './space-viewer.js';
|
||
import { logger } from '../logger.js';
|
||
import { sendOfficePreview, handleOfficePreviewError } from './office-preview.js';
|
||
import { parseToolPolicy } from '../engine/workspace-tool-policy.js';
|
||
import {
|
||
listToolCategories,
|
||
SENSITIVE_CATEGORIES,
|
||
SENSITIVE_TOOLS,
|
||
} from '../engine/tools/tool-categories.js';
|
||
import {
|
||
listTransferCandidates,
|
||
executeTransfer,
|
||
} from '../services/space-credential-transfer.js';
|
||
|
||
// cross-calendar-api.ts は parseTzOffset / monthBounds を space-api 経由で参照する。
|
||
// 実体はカレンダールートと同居させたほうが凝集が高いので space-calendar-api に移し、
|
||
// 後方互換のためここで再エクスポートする(spaceViewerOf と同じ公開境界の扱い)。
|
||
export { parseTzOffset, monthBounds } from './space-calendar-api.js';
|
||
|
||
export interface SpaceApiDeps {
|
||
repo: Repository;
|
||
dataRoot: string;
|
||
worktreeDir: string;
|
||
authActive: boolean;
|
||
/** アップロード JSON ボディの上限(MB)。未指定は 50MB(base64 は ~33% 膨張する点に注意)。 */
|
||
uploadLimitMb?: number;
|
||
/** Per-space Python packages のライブ config(loadConfig() を都度読む getter)。未配線 = 無効扱い。 */
|
||
getPythonPackagesConfig?: () => import('../config.js').PythonPackagesConfigShape | undefined;
|
||
/** ワークスペース Webhook 通知(issue #797)— test-send 実行用。null/未配線 = 501/503 応答。 */
|
||
webhookService?: import('../webhook-service.js').WebhookDeliveryService | null;
|
||
}
|
||
|
||
/**
|
||
* 認証 OFF 環境のフォールバックユーザーを返す(cross-calendar-api 等から流用)。
|
||
* 規約は viewerOf と同一(synthetic 'local' admin)。
|
||
*/
|
||
export function spaceViewerOf(req: any, authActive: boolean): Express.User {
|
||
return viewerOf(req, authActive);
|
||
}
|
||
|
||
export function createSpaceApi(deps: SpaceApiDeps): Router {
|
||
const router = Router();
|
||
const { repo, dataRoot, worktreeDir } = deps;
|
||
|
||
// body を読むルート(create / patch / upload)専用の json parser。
|
||
// グローバル json は server.ts から外したので、各ルートが自前で宣言する。
|
||
// upload を通すため 50mb(または config 指定)まで許容する。
|
||
const jsonParser = express.json({ limit: deps.uploadLimitMb ? `${deps.uploadLimitMb}mb` : '50mb' });
|
||
|
||
// 一覧(個人スペースは初回アクセス時に lazy 生成)。各スペースに閲覧者の myRole も付与
|
||
// (クライアントの editor 含むファイル編集可否判定に使う。SpaceDetail は一覧から space を引く)。
|
||
router.get('/', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
await repo.ensurePersonalSpace(viewer.id, viewer.name ?? undefined);
|
||
const spaces = await repo.listSpacesWithDisplayPrefs({ viewer });
|
||
res.json(spaces.map(s => ({ ...s, myRole: repo.getSpaceMemberRole(s.id, viewer.id) })));
|
||
});
|
||
|
||
// 詳細。閲覧者のメンバーロール(myRole)も載せる。クライアントが editor を含む
|
||
// ファイル編集可否(canEditInSpace 相当)を判定するために使う。非メンバーは null。
|
||
router.get('/:id', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const myRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
res.json({ ...space, myRole });
|
||
});
|
||
|
||
router.patch("/:id/display", jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: "not found" });
|
||
|
||
const patch: { favorite?: boolean; hidden?: boolean } = {};
|
||
if (req.body?.favorite !== undefined) {
|
||
if (typeof req.body.favorite !== "boolean") return res.status(400).json({ error: "favorite must be boolean" });
|
||
patch.favorite = req.body.favorite;
|
||
}
|
||
if (req.body?.hidden !== undefined) {
|
||
if (typeof req.body.hidden !== "boolean") return res.status(400).json({ error: "hidden must be boolean" });
|
||
patch.hidden = req.body.hidden;
|
||
}
|
||
if (patch.favorite === undefined && patch.hidden === undefined) {
|
||
return res.status(400).json({ error: "favorite or hidden is required" });
|
||
}
|
||
|
||
const prefs = repo.updateSpaceDisplayPrefs(viewer.id, space.id, patch);
|
||
res.json(prefs);
|
||
});
|
||
|
||
// 作成(案件スペース)。DB 行 → 雛形作成。雛形失敗時は行を消してロールバック。
|
||
router.post('/', jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const title = (req.body?.title ?? '').toString().trim();
|
||
if (!title) return res.status(400).json({ error: 'title is required' });
|
||
const ownerId = viewer.id === 'local' ? null : viewer.id;
|
||
const space = await repo.createSpace({
|
||
kind: 'case',
|
||
title,
|
||
description: req.body?.description?.toString() ?? '',
|
||
ownerId,
|
||
visibility: req.body?.visibility ?? 'private',
|
||
visibilityScopeOrgId: req.body?.visibilityScopeOrgId ?? null,
|
||
brandColor: req.body?.brandColor ?? null,
|
||
});
|
||
try {
|
||
// 案件スペースのフォルダ実体は data/spaces/{id}(spec §5.3)。dataRoot は
|
||
// userFolderRoot(data/users)が渡るため、scaffold の spaceDataDir が
|
||
// {dataRoot}/spaces/{id} を作る前提に合わせ dataRoot=dirname(userFolderRoot)=data を渡す。
|
||
scaffoldCaseSpace({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id, title });
|
||
// workspace_dir を確定保存
|
||
const ws = spaceWorkspaceDir(worktreeDir, space.id);
|
||
await repo.setSpaceWorkspaceDir(space.id, ws);
|
||
const fresh = await repo.getSpace(space.id, { viewer });
|
||
res.status(201).json(fresh);
|
||
} catch (err) {
|
||
logger.error(`[space-api] scaffold failed, rolling back space=${space.id} err=${(err as Error).message}`);
|
||
removeSpaceDirs({ dataRoot: dirname(dataRoot), worktreeDir, id: space.id });
|
||
await repo.hardDeleteSpace(space.id);
|
||
res.status(500).json({ error: 'failed to create space' });
|
||
}
|
||
});
|
||
|
||
// 更新(owner / admin / member.role==='owner')
|
||
router.patch('/:id', jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
const updated = await repo.updateSpace(req.params.id, {
|
||
title: req.body?.title,
|
||
description: req.body?.description,
|
||
brandColor: req.body?.brandColor,
|
||
visibility: req.body?.visibility,
|
||
visibilityScopeOrgId: req.body?.visibilityScopeOrgId,
|
||
});
|
||
res.json(updated);
|
||
});
|
||
|
||
// アーカイブ(owner / admin / member.role==='owner'、案件のみ)
|
||
router.post('/:id/archive', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const space = await repo.getSpace(req.params.id, { viewer });
|
||
if (!space) return res.status(404).json({ error: 'not found' });
|
||
const memberRole = repo.getSpaceMemberRole(space.id, viewer.id);
|
||
if (!canManageSpace(viewer, { ownerId: space.ownerId }, memberRole)) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
if (space.kind === 'personal') return res.status(400).json({ error: 'cannot archive personal space' });
|
||
await repo.archiveSpace(req.params.id);
|
||
res.json({ ok: true });
|
||
});
|
||
|
||
// Space ファイル窓ルート(/:id/files/*)は space-files-api.ts に分離(巨大関数分割)。
|
||
registerSpaceFilesRoutes(router, deps);
|
||
|
||
// カレンダー(予定 + 日次集計)は space-calendar-api.ts に分離(巨大関数分割)。
|
||
registerSpaceCalendarRoutes(router, deps);
|
||
|
||
// メンバー(協働者)CRUD は space-members-api.ts に分離(巨大関数分割)。
|
||
registerSpaceMembersRoutes(router, deps);
|
||
|
||
// 招待リンク(再利用トークン)は space-invite-api.ts に分離(巨大関数分割)。
|
||
registerSpaceInviteRoutes(router, deps);
|
||
|
||
// 公開アプリ共有リンクは space-app-share-api.ts に分離(巨大関数分割)。
|
||
registerSpaceAppShareRoutes(router, deps);
|
||
|
||
// ツールポリシー GET/PUT は space-tool-policy-api.ts に分離(巨大関数分割)。
|
||
registerSpaceToolPolicyRoutes(router, deps);
|
||
|
||
// Python パッケージ管理 GET/POST/DELETE は space-python-packages-api.ts に分離。
|
||
registerSpacePythonPackagesRoutes(router, deps);
|
||
|
||
// Webhook 通知(Discord/Slack/Teams)CRUD + test-send は space-webhooks-api.ts に分離。
|
||
registerSpaceWebhooksRoutes(router, deps);
|
||
|
||
// ─── スペース間資格情報コピー ──────────────────────────────────────
|
||
// 両スペースの管理権(canManageSpace)を持つユーザーのみ利用可能。
|
||
// 成功時に audit_log へ記録する。
|
||
|
||
/** 閲覧者が source・target 両スペースを管理できるかを確認する。 */
|
||
async function canManageBothSpaces(
|
||
viewer: Express.User,
|
||
sourceId: string,
|
||
targetId: string,
|
||
): Promise<boolean> {
|
||
const source = await repo.getSpace(sourceId, { viewer });
|
||
const target = await repo.getSpace(targetId, { viewer });
|
||
if (!source || !target) return false;
|
||
const srcRole = repo.getSpaceMemberRole(sourceId, viewer.id);
|
||
const tgtRole = repo.getSpaceMemberRole(targetId, viewer.id);
|
||
return (
|
||
canManageSpace(viewer, source, srcRole) &&
|
||
canManageSpace(viewer, target, tgtRole)
|
||
);
|
||
}
|
||
|
||
// GET /:targetId/transfer/candidates?sourceId=<id>
|
||
// 元スペースの MCP サーバー・SSH 接続の転送候補一覧を返す。
|
||
router.get('/:targetId/transfer/candidates', async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const targetId = req.params.targetId;
|
||
const sourceId = String(req.query.sourceId ?? '');
|
||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||
if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' });
|
||
if (!(await canManageBothSpaces(viewer, sourceId, targetId))) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
const db = repo.getDb();
|
||
return res.json(listTransferCandidates({ db }, sourceId, targetId));
|
||
});
|
||
|
||
// POST /:targetId/transfer
|
||
// body: { sourceId, mcpServerIds: string[], sshConnectionIds: string[] }
|
||
// 指定した MCP サーバー・SSH 接続を転送し、copied/skipped サマリを返す。
|
||
router.post('/:targetId/transfer', jsonParser, async (req, res) => {
|
||
const viewer = viewerOf(req, deps.authActive);
|
||
const targetId = req.params.targetId;
|
||
const sourceId = String(req.body?.sourceId ?? '');
|
||
if (!sourceId) return res.status(400).json({ error: 'sourceId required' });
|
||
if (sourceId === targetId) return res.status(400).json({ error: 'source and target must differ' });
|
||
|
||
const mcpServerIds = req.body?.mcpServerIds;
|
||
const sshConnectionIds = req.body?.sshConnectionIds;
|
||
if (mcpServerIds !== undefined && !Array.isArray(mcpServerIds)) {
|
||
return res.status(400).json({ error: 'mcpServerIds must be an array' });
|
||
}
|
||
if (sshConnectionIds !== undefined && !Array.isArray(sshConnectionIds)) {
|
||
return res.status(400).json({ error: 'sshConnectionIds must be an array' });
|
||
}
|
||
|
||
if (!(await canManageBothSpaces(viewer, sourceId, targetId))) {
|
||
return res.status(403).json({ error: 'forbidden' });
|
||
}
|
||
|
||
const db = repo.getDb();
|
||
const result = executeTransfer(
|
||
{ db },
|
||
{
|
||
sourceSpaceId: sourceId,
|
||
targetSpaceId: targetId,
|
||
actorId: viewer.id,
|
||
mcpServerIds: (mcpServerIds as string[]) ?? [],
|
||
sshConnectionIds: (sshConnectionIds as string[]) ?? [],
|
||
},
|
||
);
|
||
|
||
await deps.repo.addAuditLog(null, 'space.credential.transfer', viewer.id, {
|
||
sourceId,
|
||
targetId,
|
||
copied: result.copied.length,
|
||
skipped: result.skipped.length,
|
||
});
|
||
|
||
return res.json(result);
|
||
});
|
||
|
||
return router;
|
||
}
|